diff --git a/src/controllers/conclusion.controller.ts b/src/controllers/conclusion.controller.ts index 4c4214c..ac1c6b7 100644 --- a/src/controllers/conclusion.controller.ts +++ b/src/controllers/conclusion.controller.ts @@ -32,9 +32,9 @@ export class ConclusionController { return res.status(403).json({ error: 'Only the initiator can generate conclusion remarks' }); } - // Check if request is approved - if ((request as any).status !== 'APPROVED') { - return res.status(400).json({ error: 'Conclusion can only be generated for approved requests' }); + // Check if request is approved or rejected + if ((request as any).status !== 'APPROVED' && (request as any).status !== 'REJECTED') { + return res.status(400).json({ error: 'Conclusion can only be generated for approved or rejected requests' }); } // Check if AI features are enabled in admin config @@ -99,15 +99,21 @@ export class ConclusionController { requestDescription: (request as any).description, requestNumber: (request as any).requestNumber, priority: (request as any).priority, - approvalFlow: approvalLevels.map((level: any) => ({ - levelNumber: level.levelNumber, - approverName: level.approverName, - status: level.status, - comments: level.comments, - actionDate: level.actionDate, - tatHours: Number(level.tatHours || 0), - elapsedHours: Number(level.elapsedHours || 0) - })), + approvalFlow: approvalLevels.map((level: any) => { + const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null + ? Number(level.tatPercentageUsed) + : (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0); + return { + levelNumber: level.levelNumber, + approverName: level.approverName, + status: level.status, + comments: level.comments, + actionDate: level.actionDate, + tatHours: Number(level.tatHours || 0), + elapsedHours: Number(level.elapsedHours || 0), + tatPercentageUsed: tatPercentage + }; + }), workNotes: workNotes.map((note: any) => ({ userName: note.userName, message: note.message, @@ -294,9 +300,9 @@ export class ConclusionController { return res.status(403).json({ error: 'Only the initiator can finalize conclusion remarks' }); } - // Check if request is approved - if ((request as any).status !== 'APPROVED') { - return res.status(400).json({ error: 'Only approved requests can be closed' }); + // Check if request is approved or rejected + if ((request as any).status !== 'APPROVED' && (request as any).status !== 'REJECTED') { + return res.status(400).json({ error: 'Only approved or rejected requests can be closed' }); } // Find or create conclusion @@ -341,6 +347,19 @@ export class ConclusionController { logger.info(`[Conclusion] ✅ Request ${requestId} finalized and closed`); + // Automatically create summary when request is closed (idempotent - returns existing if already exists) + let summaryId = null; + try { + const { summaryService } = await import('@services/summary.service'); + const summary = await summaryService.createSummary(requestId, userId); + summaryId = (summary as any).summaryId; + logger.info(`[Conclusion] ✅ Summary ${summaryId} created automatically for closed request ${requestId}`); + } catch (summaryError: any) { + // Log error but don't fail the closure if summary creation fails + // Frontend can retry summary creation if needed + logger.error(`[Conclusion] Failed to create summary for request ${requestId}:`, summaryError); + } + // Log activity const requestMeta = getRequestMetadata(req); await activityService.log({ @@ -361,7 +380,8 @@ export class ConclusionController { requestNumber: (request as any).requestNumber, status: 'CLOSED', finalRemark: finalRemark, - finalizedAt: (conclusion as any).finalizedAt + finalizedAt: (conclusion as any).finalizedAt, + summaryId: summaryId // Include summaryId in response } }); } catch (error: any) { diff --git a/src/controllers/summary.controller.ts b/src/controllers/summary.controller.ts index 2ab0c9d..f38d89a 100644 --- a/src/controllers/summary.controller.ts +++ b/src/controllers/summary.controller.ts @@ -130,6 +130,32 @@ export class SummaryController { ResponseHandler.error(res, 'Failed to list summaries', 500, errorMessage); } } + + /** + * Get summary by requestId (checks if exists without creating) + * GET /api/v1/summaries/request/:requestId + */ + async getSummaryByRequestId(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; + const { requestId } = req.params; + + if (!requestId) { + ResponseHandler.error(res, 'requestId is required', 400); + return; + } + + const summary = await summaryService.getSummaryByRequestId(requestId, userId); + if (summary) { + ResponseHandler.success(res, summary, 'Summary retrieved successfully'); + } else { + ResponseHandler.error(res, 'Summary not found', 404); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + ResponseHandler.error(res, 'Failed to get summary', 500, errorMessage); + } + } } export const summaryController = new SummaryController(); diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 92c4601..36a209b 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -273,11 +273,12 @@ export class WorkflowController { const status = req.query.status as string | undefined; const priority = req.query.priority as string | undefined; const department = req.query.department as string | undefined; + const slaCompliance = req.query.slaCompliance as string | undefined; 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 filters = { search, status, priority, department, dateRange, startDate, endDate }; + const filters = { search, status, priority, department, slaCompliance, dateRange, startDate, endDate }; const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My initiated requests fetched'); diff --git a/src/routes/summary.routes.ts b/src/routes/summary.routes.ts index a83328a..9675078 100644 --- a/src/routes/summary.routes.ts +++ b/src/routes/summary.routes.ts @@ -32,6 +32,12 @@ router.get( asyncHandler(summaryController.listMySummaries.bind(summaryController)) ); +// Get summary by requestId (MUST come before /:summaryId) +router.get( + '/request/:requestId', + asyncHandler(summaryController.getSummaryByRequestId.bind(summaryController)) +); + // Share summary with users (MUST come before /:summaryId) router.post( '/:summaryId/share', diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index a62d49f..67e9f81 100644 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -422,7 +422,9 @@ class AIService { approvalFlow, workNotes, documents, - activities + activities, + rejectionReason, + rejectedBy } = context; // Get max remark length from admin configuration @@ -433,14 +435,30 @@ class AIService { logger.info(`[AI Service] Using max remark length: ${maxLength} characters (≈${targetWordCount} words) from admin config`); - // Summarize approvals + // Check if this is a rejected request + const isRejected = rejectionReason || rejectedBy || approvalFlow.some((a: any) => a.status === 'REJECTED'); + + // Helper function to determine TAT risk status + const getTATRiskStatus = (tatPercentage: number): string => { + if (tatPercentage < 50) return 'ON_TRACK'; + if (tatPercentage < 75) return 'AT_RISK'; + if (tatPercentage < 100) return 'CRITICAL'; + return 'BREACHED'; + }; + + // Summarize approvals with TAT risk information const approvalSummary = approvalFlow .filter((a: any) => a.status === 'APPROVED' || a.status === 'REJECTED') .map((a: any) => { + const tatPercentage = a.tatPercentageUsed !== undefined && a.tatPercentageUsed !== null + ? Number(a.tatPercentageUsed) + : (a.elapsedHours && a.tatHours ? (Number(a.elapsedHours) / Number(a.tatHours)) * 100 : 0); + const riskStatus = getTATRiskStatus(tatPercentage); const tatInfo = a.elapsedHours && a.tatHours - ? ` (completed in ${a.elapsedHours.toFixed(1)}h of ${a.tatHours}h TAT)` + ? ` (completed in ${a.elapsedHours.toFixed(1)}h of ${a.tatHours}h TAT, ${tatPercentage.toFixed(1)}% used)` : ''; - return `- Level ${a.levelNumber}: ${a.approverName} ${a.status}${tatInfo}${a.comments ? `\n Comment: "${a.comments}"` : ''}`; + const riskInfo = riskStatus !== 'ON_TRACK' ? ` [${riskStatus}]` : ''; + return `- Level ${a.levelNumber}: ${a.approverName} ${a.status}${tatInfo}${riskInfo}${a.comments ? `\n Comment: "${a.comments}"` : ''}`; }) .join('\n'); @@ -455,6 +473,11 @@ class AIService { .map((d: any) => `- ${d.fileName} (by ${d.uploadedBy})`) .join('\n'); + // Build rejection context if applicable + const rejectionContext = isRejected + ? `\n**Rejection Details:**\n- Rejected by: ${rejectedBy || 'Approver'}\n- Rejection reason: ${rejectionReason || 'Not specified'}` + : ''; + const prompt = `You are writing a closure summary for a workflow request at Royal Enfield. Write a practical, realistic conclusion that an employee would write when closing a request. **Request:** @@ -463,7 +486,7 @@ Description: ${requestDescription} Priority: ${priority} **What Happened:** -${approvalSummary || 'No approvals recorded'} +${approvalSummary || 'No approvals recorded'}${rejectionContext} **Discussions (if any):** ${workNoteSummary || 'No work notes'} @@ -473,12 +496,22 @@ ${documentSummary || 'No documents'} **YOUR TASK:** Write a brief, professional conclusion (approximately ${targetWordCount} words, max ${maxLength} characters) that: -- Summarizes what was requested and the final decision -- Mentions who approved it and any key comments -- Notes the outcome and next steps (if applicable) +${isRejected + ? `- Summarizes what was requested and explains that it was rejected +- Mentions who rejected it and the rejection reason +- Notes the outcome and any learnings or next steps +- Mentions if any approval levels were AT_RISK, CRITICAL, or BREACHED (if applicable) - Uses clear, factual language without time-specific references - Is suitable for permanent archiving and future reference - Sounds natural and human-written (not AI-generated) +- Maintains a professional and constructive tone even for rejections` + : `- Summarizes what was requested and the final decision +- Mentions who approved it and any key comments +- Mentions if any approval levels were AT_RISK, CRITICAL, or BREACHED (if applicable) +- Notes the outcome and next steps (if applicable) +- Uses clear, factual language without time-specific references +- Is suitable for permanent archiving and future reference +- Sounds natural and human-written (not AI-generated)`} **IMPORTANT:** - Be concise and direct @@ -487,7 +520,7 @@ Write a brief, professional conclusion (approximately ${targetWordCount} words, - No corporate jargon or buzzwords - No emojis or excessive formatting - Write like a professional documenting a completed process -- Focus on facts: what was requested, who approved, what was decided +- Focus on facts: what was requested, who ${isRejected ? 'rejected' : 'approved'}, what was decided - Use past tense for completed actions Write the conclusion now (remember: max ${maxLength} characters):`; diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index f6dbc92..ca31a0a 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -140,15 +140,21 @@ export class ApprovalService { requestDescription: (wf as any).description, requestNumber: (wf as any).requestNumber, priority: (wf as any).priority, - approvalFlow: approvalLevels.map((l: any) => ({ - levelNumber: l.levelNumber, - approverName: l.approverName, - status: l.status, - comments: l.comments, - actionDate: l.actionDate, - tatHours: Number(l.tatHours || 0), - elapsedHours: Number(l.elapsedHours || 0) - })), + approvalFlow: approvalLevels.map((l: any) => { + const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null + ? Number(l.tatPercentageUsed) + : (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0); + return { + levelNumber: l.levelNumber, + approverName: l.approverName, + status: l.status, + comments: l.comments, + actionDate: l.actionDate, + tatHours: Number(l.tatHours || 0), + elapsedHours: Number(l.elapsedHours || 0), + tatPercentageUsed: tatPercentage + }; + }), workNotes: workNotes.map((note: any) => ({ userName: note.userName, message: note.message, @@ -363,11 +369,11 @@ export class ApprovalService { } } } else if (action.action === 'REJECT') { - // Rejection - close workflow and mark all remaining levels as skipped + // Rejection - mark workflow as REJECTED (closure will happen when initiator finalizes conclusion) await WorkflowRequest.update( { - status: WorkflowStatus.REJECTED, - closureDate: now + status: WorkflowStatus.REJECTED + // Note: closureDate will be set when initiator finalizes the conclusion }, { where: { requestId: level.requestId } } ); @@ -387,7 +393,22 @@ export class ApprovalService { } ); - logger.info(`Level ${level.levelNumber} rejected. Workflow ${level.requestId} closed as REJECTED`); + logger.info(`Level ${level.levelNumber} rejected. Workflow ${level.requestId} marked as REJECTED. Awaiting closure from initiator.`); + + // Log rejection activity first (so it's included in AI context) + if (wf) { + activityService.log({ + requestId: level.requestId, + type: 'rejection', + user: { userId: level.approverId, name: level.approverName }, + timestamp: new Date().toISOString(), + action: 'Rejected', + details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}. Awaiting closure from initiator.`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined + }); + } + // Notify initiator and all participants if (wf) { const participants = await Participant.findAll({ where: { requestId: level.requestId } }); @@ -402,17 +423,146 @@ export class ApprovalService { requestNumber: (wf as any).requestNumber, url: `/request/${(wf as any).requestNumber}` }); - activityService.log({ - requestId: level.requestId, - type: 'rejection', - user: { userId: level.approverId, name: level.approverName }, - timestamp: new Date().toISOString(), - action: 'Rejected', - details: `Request rejected by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, - ipAddress: requestMetadata?.ipAddress || undefined, - userAgent: requestMetadata?.userAgent || undefined - }); } + + // Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved) + // This runs in the background without blocking the rejection response + (async () => { + try { + const { aiService } = await import('./ai.service'); + const { ConclusionRemark } = await import('@models/index'); + const { ApprovalLevel } = await import('@models/ApprovalLevel'); + const { WorkNote } = await import('@models/WorkNote'); + const { Document } = await import('@models/Document'); + const { Activity } = await import('@models/Activity'); + const { getConfigValue } = await import('./configReader.service'); + + // Check if AI features and remark generation are enabled in admin config + const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true'; + const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true'; + + if (!aiEnabled || !remarkGenerationEnabled) { + logger.info(`[Approval] AI conclusion generation skipped for rejected request ${level.requestId} (AI disabled)`); + return; + } + + // Check if AI service is available + const { aiService: aiSvc } = await import('./ai.service'); + if (!aiSvc.isAvailable()) { + logger.warn(`[Approval] AI service unavailable for rejected request ${level.requestId}`); + return; + } + + // Gather context for AI generation (similar to approved flow) + const approvalLevels = await ApprovalLevel.findAll({ + where: { requestId: level.requestId }, + order: [['levelNumber', 'ASC']] + }); + + const workNotes = await WorkNote.findAll({ + where: { requestId: level.requestId }, + order: [['createdAt', 'ASC']], + limit: 20 + }); + + const documents = await Document.findAll({ + where: { requestId: level.requestId }, + order: [['uploadedAt', 'DESC']] + }); + + const activities = await Activity.findAll({ + where: { requestId: level.requestId }, + order: [['createdAt', 'ASC']], + limit: 50 + }); + + // Build context object (include rejection reason) + const context = { + requestTitle: (wf as any).title, + requestDescription: (wf as any).description, + requestNumber: (wf as any).requestNumber, + priority: (wf as any).priority, + rejectionReason: action.rejectionReason || action.comments || 'No reason provided', + rejectedBy: level.approverName || level.approverEmail, + approvalFlow: approvalLevels.map((l: any) => { + const tatPercentage = l.tatPercentageUsed !== undefined && l.tatPercentageUsed !== null + ? Number(l.tatPercentageUsed) + : (l.elapsedHours && l.tatHours ? (Number(l.elapsedHours) / Number(l.tatHours)) * 100 : 0); + return { + levelNumber: l.levelNumber, + approverName: l.approverName, + status: l.status, + comments: l.comments, + actionDate: l.actionDate, + tatHours: Number(l.tatHours || 0), + elapsedHours: Number(l.elapsedHours || 0), + tatPercentageUsed: tatPercentage + }; + }), + workNotes: workNotes.map((note: any) => ({ + userName: note.userName, + message: note.message, + createdAt: note.createdAt + })), + documents: documents.map((doc: any) => ({ + fileName: doc.originalFileName || doc.fileName, + uploadedBy: doc.uploadedBy, + uploadedAt: doc.uploadedAt + })), + activities: activities.map((activity: any) => ({ + type: activity.activityType, + action: activity.activityDescription, + details: activity.activityDescription, + timestamp: activity.createdAt + })) + }; + + logger.info(`[Approval] Generating AI conclusion for rejected request ${level.requestId}...`); + + // Generate AI conclusion (will adapt to rejection context) + const aiResult = await aiSvc.generateConclusionRemark(context); + + // Create or update conclusion remark + let conclusionInstance = await ConclusionRemark.findOne({ where: { requestId: level.requestId } }); + + const conclusionData = { + aiGeneratedRemark: aiResult.remark, + aiModelUsed: aiResult.provider, + aiConfidenceScore: aiResult.confidence, + approvalSummary: { + totalLevels: approvalLevels.length, + rejectedLevel: level.levelNumber, + rejectedBy: level.approverName || level.approverEmail, + rejectionReason: action.rejectionReason || action.comments + }, + documentSummary: { + totalDocuments: documents.length, + documentNames: documents.map((d: any) => d.originalFileName || d.fileName) + }, + keyDiscussionPoints: aiResult.keyPoints, + generatedAt: new Date() + }; + + if (conclusionInstance) { + await conclusionInstance.update(conclusionData as any); + logger.info(`[Approval] ✅ AI conclusion updated for rejected request ${level.requestId}`); + } else { + await ConclusionRemark.create({ + requestId: level.requestId, + ...conclusionData, + finalRemark: null, + editedBy: null, + isEdited: false, + editCount: 0, + finalizedAt: null + } as any); + logger.info(`[Approval] ✅ AI conclusion generated for rejected request ${level.requestId}`); + } + } catch (error: any) { + logger.error(`[Approval] Failed to generate AI conclusion for rejected request ${level.requestId}:`, error); + // Don't fail the rejection if AI generation fails + } + })(); } logger.info(`Approval level ${levelId} ${action.action.toLowerCase()}ed`); diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 444baac..a34091e 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -57,6 +57,39 @@ class NotificationService { logger.info(`Subscription stored for user ${userId}. Total: ${list.length}`); } + /** + * Remove expired/invalid subscription from database and memory cache + */ + private async removeExpiredSubscription(userId: string, endpoint: string) { + try { + // Remove from database + await Subscription.destroy({ where: { endpoint } }); + logger.info(`[Notification] Removed expired subscription from DB for user ${userId}, endpoint: ${endpoint.substring(0, 50)}...`); + + // Remove from memory cache + const list = this.userIdToSubscriptions.get(userId) || []; + const filtered = list.filter((s) => s.endpoint !== endpoint); + if (filtered.length !== list.length) { + this.userIdToSubscriptions.set(userId, filtered); + logger.info(`[Notification] Removed expired subscription from memory cache for user ${userId}`); + } + } catch (error) { + logger.error(`[Notification] Failed to remove expired subscription for user ${userId}:`, error); + } + } + + /** + * Check if error indicates expired/invalid subscription + * webpush returns status codes: 410 (Gone), 404 (Not Found), 403 (Forbidden) + */ + private isExpiredSubscriptionError(err: any): boolean { + const statusCode = err?.statusCode || err?.status || err?.response?.statusCode; + // 410 Gone = subscription expired + // 404 Not Found = subscription doesn't exist + // 403 Forbidden = subscription invalid + return statusCode === 410 || statusCode === 404 || statusCode === 403; + } + /** * Send notification to users - saves to DB and sends via push/socket */ @@ -119,8 +152,14 @@ class NotificationService { await webpush.sendNotification(sub, message); await notification.update({ pushSent: true }); logger.info(`[Notification] Push sent to user ${userId}`); - } catch (err) { - logger.error(`Failed to send push to ${userId}:`, err); + } catch (err: any) { + // Check if subscription is expired/invalid + if (this.isExpiredSubscriptionError(err)) { + logger.warn(`[Notification] Expired subscription detected for user ${userId}, removing...`); + await this.removeExpiredSubscription(userId, sub.endpoint); + } else { + logger.error(`[Notification] Failed to send push to user ${userId}:`, err); + } } } } diff --git a/src/services/summary.service.ts b/src/services/summary.service.ts index 9321a97..44427e4 100644 --- a/src/services/summary.service.ts +++ b/src/services/summary.service.ts @@ -33,13 +33,18 @@ export class SummaryService { throw new Error('Only the initiator can create a summary for this request'); } - // Check if summary already exists + // Check if summary already exists - return it if it does (idempotent behavior) const existingSummary = await RequestSummary.findOne({ where: { requestId } }); if (existingSummary) { - throw new Error('Summary already exists for this request'); + // Verify the existing summary belongs to the current initiator + if ((existingSummary as any).initiatorId !== initiatorId) { + throw new Error('Only the initiator can create a summary for this request'); + } + logger.info(`Summary already exists for request ${requestId}, returning existing summary`); + return existingSummary as RequestSummary; } // Get conclusion remarks @@ -243,6 +248,40 @@ export class SummaryService { } } + /** + * Get summary by requestId (without creating it) + * Returns null if summary doesn't exist + */ + async getSummaryByRequestId(requestId: string, userId: string): Promise { + try { + const summary = await RequestSummary.findOne({ + where: { requestId } + }); + + if (!summary) { + return null; + } + + // Check access: user must be initiator or have been shared with + const isInitiator = (summary as any).initiatorId === userId; + const isShared = await SharedSummary.findOne({ + where: { + summaryId: (summary as any).summaryId, + sharedWith: userId + } + }); + + if (!isInitiator && !isShared) { + return null; // No access, return null instead of throwing error + } + + return summary as RequestSummary; + } catch (error) { + logger.error(`[Summary] Failed to get summary by requestId ${requestId}:`, error); + return null; + } + } + /** * Get summary details with all approver information */ diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index e2d24a0..749aadc 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -1432,6 +1432,7 @@ export class WorkflowService { status?: string; priority?: string; department?: string; + slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string; @@ -1534,6 +1535,66 @@ export class WorkflowService { const where = whereConditions.length > 0 ? { [Op.and]: whereConditions } : {}; + // If SLA compliance filter is active, fetch all, enrich, filter, then paginate + if (filters?.slaCompliance && filters.slaCompliance !== 'all') { + const { rows: allRows } = await WorkflowRequest.findAndCountAll({ + where, + limit: 1000, // Fetch up to 1000 records for SLA filtering + order: [['createdAt', 'DESC']], + include: [ + { association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] }, + ], + }); + + const enrichedData = await this.enrichForCards(allRows); + + // Filter by SLA compliance + const slaFilteredData = enrichedData.filter((req: any) => { + const slaCompliance = filters.slaCompliance || ''; + const slaStatus = req.currentLevelSLA?.status || + req.currentApprover?.sla?.status || + req.sla?.status || + req.summary?.sla?.status; + + if (slaCompliance.toLowerCase() === 'compliant') { + const reqStatus = (req.status || '').toString().toUpperCase(); + const isCompleted = reqStatus === 'APPROVED' || reqStatus === 'REJECTED' || reqStatus === 'CLOSED'; + if (!isCompleted) return false; + if (!slaStatus) return true; + return slaStatus !== 'breached' && slaStatus.toLowerCase() !== 'breached'; + } + + if (!slaStatus) { + return slaCompliance === 'on-track' || slaCompliance === 'on_track'; + } + + const statusMap: Record = { + 'on-track': 'on_track', + 'on_track': 'on_track', + 'approaching': 'approaching', + 'critical': 'critical', + 'breached': 'breached' + }; + + const filterStatus = statusMap[slaCompliance.toLowerCase()] || slaCompliance.toLowerCase(); + return slaStatus === filterStatus || slaStatus.toLowerCase() === filterStatus; + }); + + const totalFiltered = slaFilteredData.length; + const paginatedData = slaFilteredData.slice(offset, offset + limit); + + return { + data: paginatedData, + pagination: { + page, + limit, + total: totalFiltered, + totalPages: Math.ceil(totalFiltered / limit) || 1 + } + }; + } + + // Normal pagination (no SLA filter) const { rows, count } = await WorkflowRequest.findAndCountAll({ where, offset,