tat pause resume job logic altered

This commit is contained in:
laxmanhalaki 2025-11-28 19:13:58 +05:30
parent 755dd8fbbb
commit a4cf77eebf
11 changed files with 508 additions and 108 deletions

View File

@ -1,2 +1,2 @@
import{a as t}from"./index-BVxBNLM-.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-biwEPLZp.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-1fSSvDCY.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-U5wrbP17.js.map
import{a as t}from"./index-ZFZ_Bqjc.js";import"./radix-vendor-C2EbRL2a.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-biwEPLZp.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-1fSSvDCY.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-DZ8g8n5O.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-U5wrbP17.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-DZ8g8n5O.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

View File

@ -52,7 +52,7 @@
transition: transform 0.2s ease;
}
</style>
<script type="module" crossorigin src="/assets/index-BVxBNLM-.js"></script>
<script type="module" crossorigin src="/assets/index-ZFZ_Bqjc.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-C2EbRL2a.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">

View File

@ -40,11 +40,26 @@ export class ApprovalService {
const now = new Date();
// Calculate elapsed hours using working hours logic (with pause handling)
const pauseInfo = (level as any).isPaused ? {
isPaused: (level as any).isPaused,
// Case 1: Level is currently paused (isPaused = true)
// Case 2: Level was paused and resumed (isPaused = false but pauseElapsedHours and pauseResumeDate exist)
const isPausedLevel = (level as any).isPaused;
const wasResumed = !isPausedLevel &&
(level as any).pauseElapsedHours !== null &&
(level as any).pauseElapsedHours !== undefined &&
(level as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
// Level is currently paused - return frozen elapsed hours at pause time
isPaused: true,
pausedAt: (level as any).pausedAt,
pauseElapsedHours: (level as any).pauseElapsedHours,
pauseResumeDate: (level as any).pauseResumeDate
} : wasResumed ? {
// Level was paused but has been resumed - add pre-pause elapsed hours + time since resume
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number((level as any).pauseElapsedHours), // Pre-pause elapsed hours
pauseResumeDate: (level as any).pauseResumeDate // Actual resume timestamp
} : undefined;
const elapsedHours = await calculateElapsedWorkingHours(

View File

@ -244,18 +244,25 @@ export class PauseService {
const tatHours = Number((level as any).tatHours);
const remainingHours = Math.max(0, tatHours - pauseElapsedHours);
// Get which alerts have already been sent (to avoid re-sending on resume)
const tat50AlertSent = (level as any).tat50AlertSent || false;
const tat75AlertSent = (level as any).tat75AlertSent || false;
const tatBreached = (level as any).tatBreached || false;
// Update approval level - resume TAT
// IMPORTANT: Keep pauseElapsedHours and store resumedAt (pauseResumeDate repurposed)
// This allows SLA calculation to correctly add pre-pause elapsed time
await level.update({
isPaused: false,
pausedAt: null as any,
pausedBy: null as any,
pauseReason: null as any,
pauseResumeDate: null as any,
pauseTatStartTime: null as any,
pauseElapsedHours: null as any,
pauseResumeDate: now, // Store actual resume time (repurposed from scheduled resume date)
// pauseTatStartTime: null as any, // Keep original TAT start time for reference
// pauseElapsedHours is intentionally NOT cleared - needed for SLA calculations
status: ApprovalStatus.IN_PROGRESS,
tatStartTime: now, // Reset TAT start time to now
levelStartTime: now
tatStartTime: now, // Reset TAT start time to now for new elapsed calculation
levelStartTime: now // This is the new start time from resume
});
// Update workflow - restore previous status or default to PENDING
@ -272,15 +279,29 @@ export class PauseService {
status: previousStatus // Restore previous status (PENDING or IN_PROGRESS)
});
// Reschedule TAT jobs from resume time
// Reschedule TAT jobs from resume time - only for alerts that haven't been sent yet
if (remainingHours > 0) {
await tatSchedulerService.scheduleTatJobs(
// Calculate which thresholds are still pending based on remaining time
const percentageUsedAtPause = tatHours > 0 ? (pauseElapsedHours / tatHours) * 100 : 0;
// Only schedule jobs for thresholds that:
// 1. Haven't been sent yet
// 2. Haven't been passed yet (based on percentage used at pause)
await tatSchedulerService.scheduleTatJobsOnResume(
requestId,
(level as any).levelId,
(level as any).approverId,
remainingHours, // Use remaining hours, not original TAT
now,
priority as any
remainingHours, // Remaining TAT hours
now, // Start from now
priority as any,
{
// Pass which alerts were already sent
tat50AlertSent: tat50AlertSent,
tat75AlertSent: tat75AlertSent,
tatBreached: tatBreached,
// Pass percentage used at pause to determine which thresholds are still relevant
percentageUsedAtPause: percentageUsedAtPause
}
);
}

View File

@ -153,6 +153,178 @@ export class TatSchedulerService {
}
}
/**
* Schedule TAT jobs on resume - only schedules jobs for alerts that haven't been sent yet
* @param requestId - The workflow request ID
* @param levelId - The approval level ID
* @param approverId - The approver user ID
* @param remainingTatHours - Remaining TAT duration in hours (from resume point)
* @param startTime - Resume start time
* @param priority - Request priority
* @param alertStatus - Object indicating which alerts have already been sent and percentage used at pause
*/
async scheduleTatJobsOnResume(
requestId: string,
levelId: string,
approverId: string,
remainingTatHours: number,
startTime: Date,
priority: Priority = Priority.STANDARD,
alertStatus: {
tat50AlertSent: boolean;
tat75AlertSent: boolean;
tatBreached: boolean;
percentageUsedAtPause: number;
}
): Promise<void> {
try {
if (!tatQueue) {
logger.warn(`[TAT Scheduler] TAT queue not available (Redis not connected). Skipping TAT job scheduling on resume.`);
return;
}
const now = startTime;
const isExpress = priority === Priority.EXPRESS;
// Get current thresholds from database configuration
const thresholds = await getTatThresholds();
// Calculate original TAT from remaining + elapsed
// Example: If 35 min used (58.33%) and 25 min remaining, original TAT = 60 min
const elapsedHours = alertStatus.percentageUsedAtPause > 0
? (remainingTatHours * alertStatus.percentageUsedAtPause) / (100 - alertStatus.percentageUsedAtPause)
: 0;
const originalTatHours = elapsedHours + remainingTatHours;
logger.info(`[TAT Scheduler] ========== RESUME TAT SCHEDULING ==========`);
logger.info(`[TAT Scheduler] Request: ${requestId}, Level: ${levelId}`);
logger.info(`[TAT Scheduler] Original TAT: ${(originalTatHours * 60).toFixed(1)} min (${originalTatHours.toFixed(2)} hrs)`);
logger.info(`[TAT Scheduler] Elapsed before pause: ${(elapsedHours * 60).toFixed(1)} min (${alertStatus.percentageUsedAtPause.toFixed(1)}%)`);
logger.info(`[TAT Scheduler] Remaining after resume: ${(remainingTatHours * 60).toFixed(1)} min`);
logger.info(`[TAT Scheduler] Thresholds configured - First: ${thresholds.first}%, Second: ${thresholds.second}%`);
logger.info(`[TAT Scheduler] Alerts already sent - ${thresholds.first}%: ${alertStatus.tat50AlertSent}, ${thresholds.second}%: ${alertStatus.tat75AlertSent}, Breach: ${alertStatus.tatBreached}`);
// Jobs to schedule - only include those that haven't been sent and haven't been passed
const jobsToSchedule: Array<{
type: 'threshold1' | 'threshold2' | 'breach';
threshold: number;
alreadySent: boolean;
alreadyPassed: boolean;
hoursFromNow: number;
}> = [];
// Threshold 1 (e.g., 50%)
// Skip if: already sent OR already passed the threshold
if (!alertStatus.tat50AlertSent && alertStatus.percentageUsedAtPause < thresholds.first) {
// Calculate: How many hours from NOW until we reach this threshold?
// Formula: (thresholdHours - elapsedHours)
// thresholdHours = originalTatHours * (threshold/100)
const thresholdHours = originalTatHours * (thresholds.first / 100);
const hoursFromNow = thresholdHours - elapsedHours;
if (hoursFromNow > 0) {
jobsToSchedule.push({
type: 'threshold1',
threshold: thresholds.first,
alreadySent: false,
alreadyPassed: false,
hoursFromNow: hoursFromNow
});
logger.info(`[TAT Scheduler] → ${thresholds.first}% alert: Schedule in ${(hoursFromNow * 60).toFixed(1)} min`);
}
} else {
logger.info(`[TAT Scheduler] → ${thresholds.first}% alert: SKIP (${alertStatus.tat50AlertSent ? 'already sent' : 'already passed'})`);
}
// Threshold 2 (e.g., 75%)
if (!alertStatus.tat75AlertSent && alertStatus.percentageUsedAtPause < thresholds.second) {
const thresholdHours = originalTatHours * (thresholds.second / 100);
const hoursFromNow = thresholdHours - elapsedHours;
if (hoursFromNow > 0) {
jobsToSchedule.push({
type: 'threshold2',
threshold: thresholds.second,
alreadySent: false,
alreadyPassed: false,
hoursFromNow: hoursFromNow
});
logger.info(`[TAT Scheduler] → ${thresholds.second}% alert: Schedule in ${(hoursFromNow * 60).toFixed(1)} min`);
}
} else {
logger.info(`[TAT Scheduler] → ${thresholds.second}% alert: SKIP (${alertStatus.tat75AlertSent ? 'already sent' : 'already passed'})`);
}
// Breach (100%)
if (!alertStatus.tatBreached) {
// Breach is always scheduled for the end of remaining TAT
jobsToSchedule.push({
type: 'breach',
threshold: 100,
alreadySent: false,
alreadyPassed: false,
hoursFromNow: remainingTatHours
});
logger.info(`[TAT Scheduler] → 100% breach: Schedule in ${(remainingTatHours * 60).toFixed(1)} min`);
} else {
logger.info(`[TAT Scheduler] → 100% breach: SKIP (already sent)`);
}
if (jobsToSchedule.length === 0) {
logger.info(`[TAT Scheduler] No TAT jobs to schedule (all alerts already sent or thresholds already passed)`);
return;
}
// Calculate actual times and schedule jobs
for (const job of jobsToSchedule) {
let targetTime: Date;
if (isExpress) {
targetTime = (await addWorkingHoursExpress(now, job.hoursFromNow)).toDate();
} else {
targetTime = (await addWorkingHours(now, job.hoursFromNow)).toDate();
}
const delay = calculateDelay(targetTime);
if (delay < 0) {
logger.warn(`[TAT Scheduler] Skipping ${job.type} - calculated time is in past`);
continue;
}
const jobId = `tat-${job.type}-${requestId}-${levelId}`;
await tatQueue.add(
job.type,
{
type: job.type,
threshold: job.threshold,
requestId,
levelId,
approverId
},
{
delay: delay,
jobId: jobId,
removeOnComplete: {
age: 3600,
count: 1000
},
removeOnFail: false
}
);
logger.info(`[TAT Scheduler] ✓ Scheduled ${job.type} (${job.threshold}%) for ${dayjs(targetTime).format('YYYY-MM-DD HH:mm')} (in ${(job.hoursFromNow * 60).toFixed(1)} working minutes)`);
}
logger.info(`[TAT Scheduler] ========== RESUME SCHEDULING COMPLETE ==========`);
logger.info(`[TAT Scheduler] ✅ ${jobsToSchedule.length} TAT job(s) scheduled for request ${requestId}`);
} catch (error) {
logger.error(`[TAT Scheduler] Failed to schedule TAT jobs on resume:`, error);
throw error;
}
}
/**
* Cancel TAT jobs for a specific approval level
* Useful when an approver acts before TAT expires

View File

@ -775,17 +775,21 @@ export class WorkflowService {
const currentLevel = await ApprovalLevel.findOne({
where: {
requestId: (wf as any).requestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] as any }, // IN_PROGRESS for approval levels, not workflows
status: { [Op.in]: ['PENDING', 'IN_PROGRESS', 'PAUSED'] as any }, // Include PAUSED to show SLA for paused levels
},
order: [['levelNumber', 'ASC']],
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }]
include: [{ model: User, as: 'approver', attributes: ['userId', 'email', 'displayName'] }],
// Include pause-related fields for SLA calculation
attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName',
'tatHours', 'tatDays', 'status', 'levelStartTime', 'tatStartTime', 'levelEndTime',
'isPaused', 'pausedAt', 'pauseElapsedHours', 'pauseResumeDate', 'elapsedHours']
});
// Fetch all approval levels for this request
// Fetch all approval levels for this request (including pause fields for SLA calculation)
const approvals = await ApprovalLevel.findAll({
where: { requestId: (wf as any).requestId },
order: [['levelNumber', 'ASC']],
attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status', 'levelStartTime', 'tatStartTime']
attributes: ['levelId', 'levelNumber', 'levelName', 'approverId', 'approverEmail', 'approverName', 'tatHours', 'tatDays', 'status', 'levelStartTime', 'tatStartTime', 'isPaused', 'pausedAt', 'pauseElapsedHours', 'pauseResumeDate', 'elapsedHours']
});
// Calculate total TAT hours from all approvals
@ -798,35 +802,131 @@ export class WorkflowService {
const priority = ((wf as any).priority || 'standard').toString().toLowerCase();
// Calculate OVERALL request SLA (from submission to total deadline)
const { calculateSLAStatus } = require('@utils/tatTimeUtils');
// Calculate OVERALL request SLA based on cumulative elapsed hours from all levels
// This correctly accounts for pause periods since each level's elapsed is pause-adjusted
const { calculateSLAStatus, addWorkingHours, addWorkingHoursExpress } = require('@utils/tatTimeUtils');
const submissionDate = (wf as any).submissionDate;
const closureDate = (wf as any).closureDate;
// For completed requests, use closure_date; for active requests, use current time
const overallEndDate = closureDate || null;
let overallSLA = null;
if (submissionDate && totalTatHours > 0) {
try {
overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority, overallEndDate);
// Calculate total elapsed hours by summing from all levels (pause-adjusted)
let totalElapsedHours = 0;
for (const approval of approvals) {
const status = ((approval as any).status || '').toString().toUpperCase();
if (status === 'APPROVED' || status === 'REJECTED') {
// For completed levels, use stored elapsedHours
totalElapsedHours += Number((approval as any).elapsedHours || 0);
} else if (status === 'SKIPPED') {
continue;
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
// For active/paused levels, calculate with pause handling
const levelStartTime = (approval as any).levelStartTime || (approval as any).tatStartTime;
const levelTatHours = Number((approval as any).tatHours || 0);
if (levelStartTime && levelTatHours > 0) {
const isPausedLevel = status === 'PAUSED' || (approval as any).isPaused;
const wasResumed = !isPausedLevel &&
(approval as any).pauseElapsedHours !== null &&
(approval as any).pauseElapsedHours !== undefined &&
(approval as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
isPaused: true,
pauseElapsedHours: (approval as any).pauseElapsedHours
} : wasResumed ? {
isPaused: false,
pauseElapsedHours: Number((approval as any).pauseElapsedHours),
pauseResumeDate: (approval as any).pauseResumeDate
} : undefined;
const levelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, null, pauseInfo);
totalElapsedHours += levelSLA.elapsedHours || 0;
}
}
}
// Calculate overall SLA metrics
const totalRemainingHours = Math.max(0, totalTatHours - totalElapsedHours);
const percentageUsed = totalTatHours > 0
? Math.min(100, Math.round((totalElapsedHours / totalTatHours) * 100))
: 0;
// Determine status
let overallStatus: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
if (percentageUsed >= 100) overallStatus = 'breached';
else if (percentageUsed >= 80) overallStatus = 'critical';
else if (percentageUsed >= 60) overallStatus = 'approaching';
// Format time display
const formatTime = (hours: number) => {
if (hours < 1) return `${Math.round(hours * 60)}m`;
const wholeHours = Math.floor(hours);
const minutes = Math.round((hours - wholeHours) * 60);
if (minutes > 0) return `${wholeHours}h ${minutes}m`;
return `${wholeHours}h`;
};
// Check if any level is paused
const isAnyLevelPaused = approvals.some((a: any) =>
((a.status || '').toString().toUpperCase() === 'PAUSED' || a.isPaused === true)
);
// Calculate deadline
const deadline = priority === 'express'
? (await addWorkingHoursExpress(submissionDate, totalTatHours)).toDate()
: (await addWorkingHours(submissionDate, totalTatHours)).toDate();
overallSLA = {
elapsedHours: totalElapsedHours,
remainingHours: totalRemainingHours,
percentageUsed,
status: overallStatus,
isPaused: isAnyLevelPaused,
deadline: deadline.toISOString(),
elapsedText: formatTime(totalElapsedHours),
remainingText: formatTime(totalRemainingHours)
};
} catch (error) {
logger.error('[Workflow] Error calculating overall SLA:', error);
}
}
// Calculate current level SLA (if there's an active level)
// Calculate current level SLA (if there's an active level, including paused)
let currentLevelSLA = null;
if (currentLevel) {
const levelStartTime = (currentLevel as any).levelStartTime || (currentLevel as any).tatStartTime;
const levelTatHours = Number((currentLevel as any).tatHours || 0);
// For completed levels, use the level's completion time (if available)
// Otherwise, if request is completed, use closure_date
const levelEndDate = (currentLevel as any).completedAt || closureDate || null;
const levelEndDate = (currentLevel as any).levelEndTime || closureDate || null;
// Prepare pause info for SLA calculation
const isPausedLevel = (currentLevel as any).status === 'PAUSED' || (currentLevel as any).isPaused;
const wasResumed = !isPausedLevel &&
(currentLevel as any).pauseElapsedHours !== null &&
(currentLevel as any).pauseElapsedHours !== undefined &&
(currentLevel as any).pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
isPaused: true,
pausedAt: (currentLevel as any).pausedAt,
pauseElapsedHours: (currentLevel as any).pauseElapsedHours,
pauseResumeDate: (currentLevel as any).pauseResumeDate
} : wasResumed ? {
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number((currentLevel as any).pauseElapsedHours),
pauseResumeDate: (currentLevel as any).pauseResumeDate
} : undefined;
if (levelStartTime && levelTatHours > 0) {
try {
currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, levelEndDate);
currentLevelSLA = await calculateSLAStatus(levelStartTime, levelTatHours, priority, levelEndDate, pauseInfo);
} catch (error) {
logger.error('[Workflow] Error calculating current level SLA:', error);
}
@ -848,6 +948,13 @@ export class WorkflowService {
department: (wf as any).initiator?.department,
totalLevels: (wf as any).totalLevels,
totalTatHours: totalTatHours,
isPaused: (wf as any).isPaused || false, // Workflow pause status
pauseInfo: (wf as any).isPaused ? {
isPaused: true,
pausedAt: (wf as any).pausedAt,
pauseReason: (wf as any).pauseReason,
pauseResumeDate: (wf as any).pauseResumeDate,
} : null,
currentLevel: currentLevel ? (currentLevel as any).levelNumber : null,
currentApprover: currentLevel ? {
userId: (currentLevel as any).approverId,
@ -855,7 +962,9 @@ export class WorkflowService {
name: (currentLevel as any).approverName,
levelStartTime: (currentLevel as any).levelStartTime,
tatHours: (currentLevel as any).tatHours,
sla: currentLevelSLA, // ← Backend-calculated SLA for current level
isPaused: (currentLevel as any).status === 'PAUSED' || (currentLevel as any).isPaused,
pauseElapsedHours: (currentLevel as any).pauseElapsedHours,
sla: currentLevelSLA, // ← Backend-calculated SLA for current level (includes pause handling)
} : null,
approvals: approvals.map((a: any) => ({
levelId: a.levelId,
@ -2576,12 +2685,25 @@ export class WorkflowService {
if (levelStartTime && tatHours > 0) {
try {
// Prepare pause info for SLA calculation if level is paused
// Prepare pause info for SLA calculation
// Case 1: Level is currently paused
// Case 2: Level was paused and resumed (pauseElapsedHours and pauseResumeDate are set)
const wasResumed = !isPausedLevel &&
approval.pauseElapsedHours !== null &&
approval.pauseElapsedHours !== undefined &&
approval.pauseResumeDate !== null;
const pauseInfo = isPausedLevel ? {
isPaused: true,
pausedAt: approval.pausedAt,
pauseElapsedHours: approval.pauseElapsedHours,
pauseResumeDate: approval.pauseResumeDate
} : wasResumed ? {
// Level was paused but has been resumed
isPaused: false,
pausedAt: null,
pauseElapsedHours: Number(approval.pauseElapsedHours), // Pre-pause elapsed hours
pauseResumeDate: approval.pauseResumeDate // Actual resume timestamp
} : undefined;
// Get comprehensive SLA status from backend utility
@ -2618,13 +2740,83 @@ export class WorkflowService {
return approvalData;
}));
// Calculate overall request SLA
// Calculate overall request SLA based on cumulative elapsed hours from all levels
// This correctly accounts for pause periods since each level's elapsedHours is pause-adjusted
const submissionDate = (workflow as any).submissionDate;
const totalTatHours = updatedApprovals.reduce((sum, a) => sum + Number(a.tatHours || 0), 0);
let overallSLA = null;
if (submissionDate && totalTatHours > 0) {
overallSLA = await calculateSLAStatus(submissionDate, totalTatHours, priority);
// Calculate total elapsed hours by summing elapsed hours from all levels
// This ensures pause periods are correctly excluded from the overall calculation
let totalElapsedHours = 0;
for (const approval of updatedApprovals) {
const status = (approval.status || '').toString().toUpperCase();
if (status === 'APPROVED' || status === 'REJECTED') {
// For completed levels, use the stored elapsedHours (already pause-adjusted from when level was completed)
totalElapsedHours += Number(approval.elapsedHours || 0);
} else if (status === 'SKIPPED') {
// Skipped levels don't contribute to elapsed time
continue;
} else if (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') {
// For active/paused levels, use the SLA-calculated elapsedHours (pause-adjusted)
if (approval.sla?.elapsedHours !== undefined) {
totalElapsedHours += Number(approval.sla.elapsedHours);
} else {
totalElapsedHours += Number(approval.elapsedHours || 0);
}
}
// WAITING levels haven't started yet, so no elapsed time
}
// Calculate overall SLA metrics based on cumulative elapsed hours
const totalRemainingHours = Math.max(0, totalTatHours - totalElapsedHours);
const percentageUsed = totalTatHours > 0
? Math.min(100, Math.round((totalElapsedHours / totalTatHours) * 100))
: 0;
// Determine overall status
let overallStatus: 'on_track' | 'approaching' | 'critical' | 'breached' = 'on_track';
if (percentageUsed >= 100) {
overallStatus = 'breached';
} else if (percentageUsed >= 80) {
overallStatus = 'critical';
} else if (percentageUsed >= 60) {
overallStatus = 'approaching';
}
// Format time display
const formatTime = (hours: number) => {
if (hours < 1) return `${Math.round(hours * 60)}m`;
const wholeHours = Math.floor(hours);
const minutes = Math.round((hours - wholeHours) * 60);
if (minutes > 0) return `${wholeHours}h ${minutes}m`;
return `${wholeHours}h`;
};
// Check if any level is currently paused
const isAnyLevelPaused = updatedApprovals.some(a =>
(a.status || '').toString().toUpperCase() === 'PAUSED' || a.isPaused === true
);
// Calculate deadline using the original method (for deadline display only)
const { addWorkingHours, addWorkingHoursExpress } = require('@utils/tatTimeUtils');
const deadline = priority === 'express'
? (await addWorkingHoursExpress(submissionDate, totalTatHours)).toDate()
: (await addWorkingHours(submissionDate, totalTatHours)).toDate();
overallSLA = {
elapsedHours: totalElapsedHours,
remainingHours: totalRemainingHours,
percentageUsed,
status: overallStatus,
isPaused: isAnyLevelPaused,
deadline: deadline.toISOString(),
elapsedText: formatTime(totalElapsedHours),
remainingText: formatTime(totalRemainingHours)
};
}
// Update summary to include comprehensive SLA
@ -2841,24 +3033,24 @@ export class WorkflowService {
// For direct submissions, createWorkflow already logs "Initial request submitted"
if (existingActivities > 1) {
// This is a saved draft being submitted later
activityService.log({
requestId: (updated as any).requestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
activityService.log({
requestId: (updated as any).requestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Draft submitted',
details: `Draft request "${workflowTitle}" submitted for approval by ${initiatorName}`
});
});
} else {
// Direct submission - just update the status, createWorkflow already logged the activity
activityService.log({
requestId: (updated as any).requestId,
activityService.log({
requestId: (updated as any).requestId,
type: 'submitted',
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
timestamp: new Date().toISOString(),
action: 'Request submitted',
details: `Request "${workflowTitle}" submitted for approval`
});
});
}
const current = await ApprovalLevel.findOne({