From 47fabeb15e9d0bd8fc4b9771a53e39a31b5b8de9 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 4 Feb 2026 20:27:17 +0530 Subject: [PATCH] new attribute added for request status as worflow state , and filter and summary related isus fixed which came after migartion --- debug-finalize.ts | 43 + docs/PLAN_STATUS_REFINEMENT.md | 61 + docs/STATUS_ARCHITECTURE.md | 55 + src/controllers/conclusion.controller.ts | 14 +- src/controllers/dashboard.controller.ts | 4 +- src/controllers/workflow.controller.ts | 10 +- src/models/mongoose/WorkflowRequest.schema.ts | 11 +- src/services/ai.service.ts | 9 + src/services/dashboard.service.ts | 1073 +++++++++++++++-- src/services/dealerClaim.service.ts | 3 +- src/services/pause.service.ts | 2 + src/services/summary.service.ts | 13 +- src/services/user.service.ts | 20 + src/services/workflow.service.ts | 251 +++- src/types/common.types.ts | 3 +- src/utils/tatTimeUtils.ts | 36 +- verify-dashboard-filtering.ts | 85 ++ 17 files changed, 1512 insertions(+), 181 deletions(-) create mode 100644 debug-finalize.ts create mode 100644 docs/PLAN_STATUS_REFINEMENT.md create mode 100644 docs/STATUS_ARCHITECTURE.md create mode 100644 verify-dashboard-filtering.ts diff --git a/debug-finalize.ts b/debug-finalize.ts new file mode 100644 index 0000000..573a81f --- /dev/null +++ b/debug-finalize.ts @@ -0,0 +1,43 @@ + +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; +import dns from 'dns'; +import { WorkflowRequestModel } from './src/models/mongoose/WorkflowRequest.schema'; + +dotenv.config(); + +async function check() { + try { + const mongoUri = process.env.MONGO_URI || process.env.MONGODB_URL; + if (!mongoUri) { + console.error('MONGO_URI not found in .env'); + process.exit(1); + } + + if (mongoUri.startsWith('mongodb+srv://')) { + dns.setServers(['8.8.8.8', '8.8.4.4', '1.1.1.1', '1.0.0.1']); + } + + await mongoose.connect(mongoUri); + console.log('✅ Connected to MongoDB'); + + const requests = await WorkflowRequestModel.find({ + $or: [ + { conclusionRemark: { $exists: true, $ne: null } }, + { workflowState: 'CLOSED' } + ] + }).sort({ updatedAt: -1 }).limit(10); + + console.log('Results (Last 10 finalized/closed):'); + requests.forEach(r => { + console.log(`- REQ: ${r.requestNumber}, Status: ${r.status}, State: ${r.workflowState}, HasRemark: ${!!r.conclusionRemark}`); + }); + + process.exit(0); + } catch (error) { + console.error('Check failed:', error); + process.exit(1); + } +} + +check(); diff --git a/docs/PLAN_STATUS_REFINEMENT.md b/docs/PLAN_STATUS_REFINEMENT.md new file mode 100644 index 0000000..ebe0860 --- /dev/null +++ b/docs/PLAN_STATUS_REFINEMENT.md @@ -0,0 +1,61 @@ +# Implementation Plan: Status Ambiguity Refinement + +This document outlines the specific code changes required to implement the **Dual-Key Status Architecture**. + +## 1. Goal +Decouple the business outcome (Approved/Rejected) from the lifecycle state (Open/Closed/Draft) to ensure transparency in finalized requests. + +## 2. Schema Changes + +### `WorkflowRequest.schema.ts` +- **Update `status` Enum**: Remove `CLOSED` and `CANCELLED`. +- **Add `workflowState`**: + - Type: `String` + - Enum: `['DRAFT', 'OPEN', 'CLOSED']` + - Default: `'DRAFT'` + - Index: `true` + +## 3. Logic Updates + +### A. Workflow Creation (`WorkflowService.createWorkflow`) +- Initialize `status: 'DRAFT'`. +- Initialize `workflowState: 'DRAFT'`. +- Set `isDraft: true`. + +### B. Workflow Submission (`WorkflowService.submitRequest`) +- Update `status: 'PENDING'`. +- Update `workflowState: 'OPEN'`. +- Set `isDraft: false`. + +### C. Approval/Rejection (`WorkflowService`) +- When approved at a level: Keep `status` as `IN_PROGRESS` or set to `APPROVED` if final. +- When rejected: Set `status` to `REJECTED`. +- **Crucial**: The `workflowState` remains `OPEN` during these actions. + +### D. Finalization (`ConclusionController.finalizeConclusion`) +- **Current Behavior**: Sets `status = 'CLOSED'`. +- **New Behavior**: + - Sets `workflowState = 'CLOSED'`. + - **Does NOT** change `status`. The `status` will remain `APPROVED` or `REJECTED`. + - Sets `closureDate = new Date()`. + +### E. Pause Logic (`PauseMongoService`) +- Set `status = 'PAUSED'`. +- Set `isPaused = true`. +- Keep `workflowState = 'OPEN'`. + +## 4. Dashboard & KPI Updates (`DashboardMongoService`) + +### `getRequestStats` +- Update the aggregation pipeline to group by `workflowState`. +- `OPEN` category will now include all requests where `workflowState == 'OPEN'`. +- `CLOSED` category will now include all requests where `workflowState == 'CLOSED'`. +- This ensures that a "Closed" count on the dashboard includes both Approved and Rejected requests that have been finalized. + +### `getTATEfficiency` +- Update match criteria to `workflowState: 'CLOSED'` instead of `status: 'CLOSED'`. + +## 5. Filter Alignment (`listWorkflowsInternal`) +- Update the status filter to handle the new field mapping. +- If user filters by `status: 'CLOSED'`, the query will target `workflowState: 'CLOSED'`. +- If user filters by `status: 'APPROVED'`, the query will target `status: 'APPROVED'`. diff --git a/docs/STATUS_ARCHITECTURE.md b/docs/STATUS_ARCHITECTURE.md new file mode 100644 index 0000000..0a2e9bb --- /dev/null +++ b/docs/STATUS_ARCHITECTURE.md @@ -0,0 +1,55 @@ +# Dual-Key Status Architecture + +This document defines the status management system for the Royal Enfield Workflow application. It uses a "Dual-Key" approach to resolve ambiguity between request lifecycles and business outcomes. + +## 1. Core Concepts + +| Key | Purpose | Possible Values | +| :--- | :--- | :--- | +| **`status`** | **Business Outcome**. Tells you *what* happened or the current granular action. | `DRAFT`, `PENDING`, `IN_PROGRESS`, `APPROVED`, `REJECTED`, `PAUSED` | +| **`workflowState`** | **Lifecycle State**. Tells you *where* the request is in its journey. | `DRAFT`, `OPEN`, `CLOSED` | + +--- + +## 2. Status Mapping Table + +The `workflowState` is automatically derived from the `status` and the finalization event (Conclusion Remark). + +| Primary Status | Finalized? | workflowState | Description | +| :--- | :--- | :--- | :--- | +| `DRAFT` | No | `DRAFT` | Request is being prepared by the initiator. | +| `PENDING` | No | `OPEN` | Waiting for first level activation or system processing. | +| `IN_PROGRESS` | No | `OPEN` | Actively moving through approval levels. | +| `PAUSED` | No | `OPEN` | Temporarily frozen; `isPaused` flag is `true`. | +| `APPROVED` | No | `OPEN` | All levels approved, but initiator hasn't written the final conclusion. | +| `REJECTED` | No | `OPEN` | Rejected by an approver, but initiator hasn't acknowledged/finalized. | +| **`APPROVED`** | **Yes** | **`CLOSED`** | **Final state: Approved and Archived.** | +| **`REJECTED`** | **Yes** | **`CLOSED`** | **Final state: Rejected and Archived.** | + +--- + +## 3. Ambiguity Resolution (The "Why") + +Previously, the system changed `status` to `CLOSED` after finalization, which destroyed the information about whether the request was Approved or Rejected. + +**Corrected Behavior:** +- **Outcome remains visible**: A finalized request will now keep its `status` as `APPROVED` or `REJECTED`. +- **Filtering made easy**: Dashboard charts use `workflowState: 'CLOSED'` to count all finished work, while list filters use `status: 'APPROVED'` to find specific results. + +--- + +## 4. Technical Implementation Notes + +### Schema Changes +- **`WorkflowRequest`**: Added `workflowState` (String, Indexed). +- **`status` Enum**: Removed `CLOSED` (deprecated) and `CANCELLED`. + +### Transition Logic +1. **Approval/Rejection**: Updates `status` to `APPROVED` or `REJECTED`. `workflowState` remains `OPEN`. +2. **Finalization (Conclusion)**: Triggered by initiator. Updates `workflowState` to `CLOSED`. **Does NOT change `status`.** +3. **Pause**: Set `status` to `PAUSED` and `isPaused: true`. `workflowState` stays `OPEN`. + +### Impacted Services +- `DashboardMongoService`: Uses `workflowState` for Facet/KPI counts. +- `WorkflowService`: Filter logic updated to respect both keys. +- `ConclusionController`: `finalizeConclusion` logic updated to toggle `workflowState`. diff --git a/src/controllers/conclusion.controller.ts b/src/controllers/conclusion.controller.ts index e1fbf15..96e41d8 100644 --- a/src/controllers/conclusion.controller.ts +++ b/src/controllers/conclusion.controller.ts @@ -25,7 +25,7 @@ export class ConclusionController { } // Check if user is the initiator (compare userId strings) - if ((request as any).initiatorId !== userId) { + if ((request as any).initiator.userId !== userId) { return res.status(403).json({ error: 'Only the initiator can generate conclusion remarks' }); } @@ -98,7 +98,7 @@ export class ConclusionController { : (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0); return { levelNumber: level.levelNumber, - approverName: level.approverName, + approverName: level.approver?.name || level.approverName || 'Unknown', status: level.status, comments: level.comments, actionDate: level.actionDate, @@ -232,7 +232,7 @@ export class ConclusionController { } // Check if user is the initiator - if ((request as any).initiatorId !== userId) { + if ((request as any).initiator.userId !== userId) { return res.status(403).json({ error: 'Only the initiator can update conclusion remarks' }); } @@ -287,10 +287,10 @@ export class ConclusionController { } // Fetch initiator manually - const initiator = await User.findOne({ userId: (request as any).initiatorId }); + const initiator = await User.findOne({ userId: (request as any).initiator.userId }); // Check if user is the initiator - if ((request as any).initiatorId !== userId) { + if ((request as any).initiator.userId !== userId) { return res.status(403).json({ error: 'Only the initiator can finalize conclusion remarks' }); } @@ -333,8 +333,8 @@ export class ConclusionController { await conclusion.save(); } - // Update request status to CLOSED - request.status = 'CLOSED'; + // Update request workflowState to CLOSED (keep granular status as APPROVED/REJECTED) + request.workflowState = 'CLOSED'; (request as any).conclusionRemark = finalRemark; (request as any).closureDate = new Date(); await request.save(); diff --git a/src/controllers/dashboard.controller.ts b/src/controllers/dashboard.controller.ts index 970148a..32570a0 100644 --- a/src/controllers/dashboard.controller.ts +++ b/src/controllers/dashboard.controller.ts @@ -53,6 +53,7 @@ export class DashboardController { const approverType = req.query.approverType as 'current' | 'any' | undefined; const search = req.query.search as string | undefined; const slaCompliance = req.query.slaCompliance as string | undefined; + const lifecycle = req.query.lifecycle as string | undefined; const viewAsUser = req.query.viewAsUser === 'true'; // When true, treat admin as normal user const stats = await this.dashboardService.getRequestStats( @@ -69,7 +70,8 @@ export class DashboardController { approverType, search, slaCompliance, - viewAsUser + viewAsUser, + lifecycle ); res.json({ diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 75cd46a..56920ff 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -485,6 +485,7 @@ export class WorkflowController { dateRange: req.query.dateRange as string | undefined, startDate: req.query.startDate as string | undefined, endDate: req.query.endDate as string | undefined, + lifecycle: req.query.lifecycle as string | undefined, }; // USE MONGODB SERVICE FOR LISTING @@ -514,8 +515,9 @@ export class WorkflowController { 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 lifecycle = req.query.lifecycle as string | undefined; - const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; + const filters = { search, status, priority, department, initiator, approverName: approver, approverType, slaCompliance, dateRange, startDate, endDate, lifecycle }; const result = await workflowServiceMongo.listMyRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My requests fetched'); @@ -548,8 +550,9 @@ export class WorkflowController { 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 lifecycle = req.query.lifecycle as string | undefined; - const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; + const filters = { search, status, priority, templateType, department, initiator, approverName: approver, approverType, slaCompliance, dateRange, startDate, endDate, lifecycle }; const result = await workflowServiceMongo.listParticipantRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'Participant requests fetched'); @@ -578,8 +581,9 @@ export class WorkflowController { 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 lifecycle = req.query.lifecycle as string | undefined; - const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate }; + const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate, lifecycle }; const result = await workflowServiceMongo.listMyInitiatedRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My initiated requests fetched'); diff --git a/src/models/mongoose/WorkflowRequest.schema.ts b/src/models/mongoose/WorkflowRequest.schema.ts index 2a1739b..6c1e71c 100644 --- a/src/models/mongoose/WorkflowRequest.schema.ts +++ b/src/models/mongoose/WorkflowRequest.schema.ts @@ -17,7 +17,8 @@ export interface IWorkflowRequest extends Document { title: string; description: string; priority: 'STANDARD' | 'EXPRESS'; - status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'APPROVED' | 'REJECTED' | 'CLOSED' | 'PAUSED' | 'CANCELLED'; + status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'APPROVED' | 'REJECTED' | 'PAUSED'; + workflowState: 'DRAFT' | 'OPEN' | 'CLOSED'; // Flattened/Cached Fields for KPIs currentLevel: number; // Display purposes - can become stale when levels shift @@ -68,7 +69,13 @@ const WorkflowRequestSchema = new Schema({ priority: { type: String, enum: ['STANDARD', 'EXPRESS'], default: 'STANDARD' }, status: { type: String, - enum: ['DRAFT', 'PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'CLOSED', 'PAUSED', 'CANCELLED'], + enum: ['DRAFT', 'PENDING', 'IN_PROGRESS', 'APPROVED', 'REJECTED', 'PAUSED'], + default: 'DRAFT', + index: true + }, + workflowState: { + type: String, + enum: ['DRAFT', 'OPEN', 'CLOSED'], default: 'DRAFT', index: true }, diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts index 7871096..81fab1d 100644 --- a/src/services/ai.service.ts +++ b/src/services/ai.service.ts @@ -264,6 +264,14 @@ class AIService { // Use Vertex AI to generate text let remarkText = await this.generateText(prompt); + // STRIP MARKDOWN CODE BLOCKS (Clean up AI response) + // Sometimes AI wraps HTML in ```html ... ``` even when asked not to + remarkText = remarkText + .replace(/^```html\s*/i, '') // Remove opening ```html + .replace(/^```\s*/i, '') // Remove opening ``` + .replace(/\s*```$/, '') // Remove closing ``` + .trim(); // Trim whitespace + // Get max length from config for logging const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000'); const maxLength = parseInt(maxLengthStr || '2000', 10); @@ -442,6 +450,7 @@ ${isRejected

Outcome: [Final outcome]

- Keep HTML clean and minimal - no inline styles, no divs, no classes - The HTML should render nicely in a rich text editor +- IMPORTANT: DO NOT wrap the output in markdown code blocks (e.g., \`\`\`html ... \`\`\`). Return ONLY the raw HTML string. Write the conclusion now in HTML format. STRICT LIMIT: ${maxLength} characters maximum (including HTML tags). Prioritize and condense if needed:`; diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts index 8271a8d..32de8f8 100644 --- a/src/services/dashboard.service.ts +++ b/src/services/dashboard.service.ts @@ -3,8 +3,12 @@ import { UserModel } from '../models/mongoose/User.schema'; import { ApprovalLevelModel } from '../models/mongoose/ApprovalLevel.schema'; import { ActivityModel } from '../models/mongoose/Activity.schema'; import { WorkNoteModel } from '../models/mongoose/WorkNote.schema'; +import { ConclusionRemarkModel } from '../models/mongoose/ConclusionRemark.schema'; +import { DocumentModel } from '../models/mongoose/Document.schema'; +import { ParticipantModel } from '../models/mongoose/Participant.schema'; import dayjs from 'dayjs'; import logger from '../utils/logger'; +import { calculateSLAStatus } from '../utils/tatTimeUtils'; interface DateRangeFilter { start: Date; @@ -15,7 +19,7 @@ export class DashboardMongoService { /** * Parse date range string to Date objects */ - private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): DateRangeFilter { + private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): DateRangeFilter | null { if (dateRange === 'custom' && startDate && endDate) { return { start: dayjs(startDate).startOf('day').toDate(), @@ -23,6 +27,8 @@ export class DashboardMongoService { }; } + if (!dateRange || dateRange === 'all') return null; + const now = dayjs(); switch (dateRange) { case 'today': @@ -40,11 +46,15 @@ export class DashboardMongoService { case 'year': return { start: now.startOf('year').toDate(), end: now.endOf('year').toDate() }; default: - // Default to last 30 days - return { - start: now.subtract(30, 'day').startOf('day').toDate(), - end: now.endOf('day').toDate() - }; + // If it's not a known keyword, try parsing it as a number of days + const days = parseInt(dateRange, 10); + if (!isNaN(days)) { + return { + start: now.subtract(days, 'day').startOf('day').toDate(), + end: now.endOf('day').toDate() + }; + } + return null; } } @@ -65,31 +75,71 @@ export class DashboardMongoService { approverType?: 'current' | 'any', search?: string, slaCompliance?: string, - viewAsUser?: boolean + viewAsUser?: boolean, + lifecycle?: string ) { const applyDateRange = dateRange !== 'all' && dateRange !== undefined; const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; + const lifecycleFilter = lifecycle || 'all'; const user = await UserModel.findOne({ userId }); const isAdmin = !viewAsUser && user?.role === 'ADMIN'; - const matchStage: any = { isDraft: false, isDeleted: false }; + const matchStage: any = { isDeleted: false }; if (!isAdmin) { - matchStage['initiator.userId'] = userId; - } - - if (applyDateRange && range) { + const participations = await ParticipantModel.find({ userId }).distinct('requestId'); matchStage.$or = [ - { submissionDate: { $gte: range.start, $lte: range.end } }, - { $and: [{ submissionDate: null }, { createdAt: { $gte: range.start, $lte: range.end } }] } + { 'initiator.userId': userId }, + { 'requestId': { $in: participations } } ]; } + // Handle Draft Visibility in Stats: + // - If we are filtering by a specific initiatorId (e.g. "My Requests" stats), allow drafts + // - Otherwise, exclude drafts from overall stats + if (initiator && initiator !== 'all') { + matchStage.isDraft = { $in: [true, false] }; + } else { + matchStage.isDraft = false; + } + + if (applyDateRange && range) { + const dateFilter = { + $or: [ + { submissionDate: { $gte: range.start, $lte: range.end } }, + { $and: [{ submissionDate: null }, { createdAt: { $gte: range.start, $lte: range.end } }] } + ] + }; + if (matchStage.$or) { + // If we already have an $or (the involvement filter), we must combine via $and + matchStage.$and = [ + { $or: matchStage.$or }, + dateFilter + ]; + delete matchStage.$or; + } else { + matchStage.$or = dateFilter.$or; + } + } + + // 1.1 Handle Lifecycle Filter (Open vs Closed) + if (lifecycleFilter !== 'all') { + if (lifecycleFilter === 'open') { + matchStage.workflowState = 'OPEN'; + } else if (lifecycleFilter === 'closed') { + matchStage.workflowState = 'CLOSED'; + } + } + + // 1.2 Handle Outcome Status Filter if (status && status !== 'all') { const statusUpper = status.toUpperCase(); if (statusUpper === 'PENDING') { matchStage.status = { $in: ['PENDING', 'IN_PROGRESS'] }; + } else if (statusUpper === 'CLOSED') { + // If they specifically filter status by 'CLOSED', they likely mean the CLOSED lifecycle + matchStage.workflowState = 'CLOSED'; } else { matchStage.status = statusUpper; } @@ -101,44 +151,142 @@ export class DashboardMongoService { if (initiator && initiator !== 'all') matchStage['initiator.userId'] = initiator; if (search && search.trim()) { - matchStage.$or = [ - { title: { $regex: search.trim(), $options: 'i' } }, - { description: { $regex: search.trim(), $options: 'i' } }, - { requestNumber: { $regex: search.trim(), $options: 'i' } } - ]; + const searchFilter = { + $or: [ + { title: { $regex: search.trim(), $options: 'i' } }, + { description: { $regex: search.trim(), $options: 'i' } }, + { requestNumber: { $regex: search.trim(), $options: 'i' } } + ] + }; + + if (matchStage.$and) { + matchStage.$and.push(searchFilter); + } else if (matchStage.$or) { + matchStage.$and = [ + { $or: matchStage.$or }, + searchFilter + ]; + delete matchStage.$or; + } else { + matchStage.$or = searchFilter.$or; + } + } + + // Approver & SLA Filters (Deep Filter requires pipeline) + const pipeline: any[] = [{ $match: matchStage }]; + + if (approver) { + const approverRegex = { $regex: approver, $options: 'i' }; + const approverMatch = { + $or: [ + { 'approver.name': approverRegex }, + { 'approver.userId': approver } + ] + }; + + if (approverType === 'current') { + pipeline.push( + { + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLvl: "$currentLevel" }, + pipeline: [ + { $match: { $expr: { $and: [{ $eq: ["$requestId", "$$reqId"] }, { $eq: ["$levelNumber", "$$currLvl"] }] } } } + ], + as: 'current_step_filter' + } + }, + { + $match: { + $or: [ + { 'current_step_filter.approver.name': approverRegex }, + { 'current_step_filter.approver.userId': approver } + ] + } + } + ); + } else { + pipeline.push( + { + $lookup: { + from: 'approval_levels', + localField: 'requestId', + foreignField: 'requestId', + as: 'matches_approvers' + } + }, + { + $match: { + $or: [ + { 'matches_approvers.approver.name': approverRegex }, + { 'matches_approvers.approver.userId': approver } + ] + } + } + ); + } + } + + if (slaCompliance && slaCompliance !== 'all') { + pipeline.push( + { + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLevelId: "$currentLevelId", currLvl: "$currentLevel" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$requestId", "$$reqId"] }, + { $or: [{ $eq: ["$levelId", "$$currLevelId"] }, { $and: [{ $eq: [{ $type: "$$currLevelId" }, "missing"] }, { $eq: ["$levelNumber", "$$currLvl"] }] }] } + ] + } + } + } + ], + as: 'active_sla_step' + } + }, + { $match: { 'active_sla_step.tat.isBreached': slaCompliance === 'breached' } } + ); } // Aggregate Stats - const stats = await WorkflowRequestModel.aggregate([ - { $match: matchStage }, - { - $facet: { - byStatus: [ - { - $group: { - _id: { - $cond: { - if: { $in: ["$status", ["PENDING", "IN_PROGRESS"]] }, - then: "PENDING", - else: "$status" - } - }, - count: { $sum: 1 } - } + pipeline.push({ + $facet: { + byStatus: [ + { + $group: { + _id: { + $switch: { + branches: [ + { case: { $eq: ["$workflowState", "DRAFT"] }, then: "DRAFT" }, + { case: { $in: ["$status", ["APPROVED"]] }, then: "APPROVED" }, + { case: { $in: ["$status", ["REJECTED"]] }, then: "REJECTED" }, + { case: { $in: ["$status", ["PENDING", "IN_PROGRESS"]] }, then: "PENDING" }, + { case: { $eq: ["$workflowState", "CLOSED"] }, then: "CLOSED" } + ], + default: "$status" + } + }, + count: { $sum: 1 } } - ], - specialCounts: [ - { - $group: { - _id: null, - total: { $sum: 1 }, - paused: { $sum: { $cond: ["$isPaused", 1, 0] } } - } + } + ], + specialCounts: [ + { + $group: { + _id: null, + total: { $sum: 1 }, + paused: { $sum: { $cond: ["$isPaused", 1, 0] } } } - ] - } + } + ] } - ]); + }); + + const stats = await WorkflowRequestModel.aggregate(pipeline); const statusMap: any = { PENDING: 0, APPROVED: 0, REJECTED: 0, CLOSED: 0 }; const results = stats[0] || { byStatus: [], specialCounts: [] }; @@ -176,7 +324,13 @@ export class DashboardMongoService { const user = await UserModel.findOne({ userId }); const isAdmin = !viewAsUser && user?.role === 'ADMIN'; - const matchStage: any = { status: 'CLOSED', isDraft: false }; + const matchStage: any = { + $or: [ + { workflowState: 'CLOSED' }, + { workflowState: { $exists: false }, status: 'CLOSED' } + ], + isDraft: false + }; if (!isAdmin) matchStage['initiator.userId'] = userId; if (applyDateRange && range) { @@ -224,7 +378,7 @@ export class DashboardMongoService { return { avgTATCompliance: compliance, avgCycleTimeHours: Math.round(m.avgCycleTime * 10) / 10, - avgCycleTimeDays: Math.round((m.avgCycleTime / 24) * 10) / 10, + avgCycleTimeDays: Math.round((m.avgCycleTime / 8) * 10) / 10, delayedWorkflows: m.breached, totalCompleted: m.total, compliantWorkflows: m.total - m.breached, @@ -274,15 +428,15 @@ export class DashboardMongoService { const match: any = {}; if (applyDateRange && range) match.createdAt = { $gte: range.start, $lte: range.end }; - const notesCount = await WorkNoteModel.countDocuments(match); - const activitiesCount = await ActivityModel.countDocuments(match); + const [notesCount, documentsCount] = await Promise.all([ + WorkNoteModel.countDocuments(match), + DocumentModel.countDocuments({ ...match, isDeleted: false }) + ]); return { - workNotesCount: notesCount, - documentsCount: 0, - averageParticipants: 0, - activeUsers: Math.round(activitiesCount / 10), - changeFromPrevious: { notes: "+0", activity: "+0%" } + workNotesAdded: notesCount, + attachmentsUploaded: documentsCount, + changeFromPrevious: { workNotes: "+0", attachments: "+0" } }; } @@ -290,11 +444,40 @@ export class DashboardMongoService { * Get AI and Closure Insights */ async getAIInsights(userId: string, dateRange?: string, startDate?: string, endDate?: string, viewAsUser?: boolean) { + const applyDateRange = dateRange !== 'all' && dateRange !== undefined; + const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; + + const match: any = {}; + if (applyDateRange && range) match.createdAt = { $gte: range.start, $lte: range.end }; + + const stats = await ConclusionRemarkModel.aggregate([ + { $match: match }, + { + $group: { + _id: null, + total: { $sum: 1 }, + aiGenerated: { $sum: { $cond: [{ $ifNull: ["$aiGeneratedRemark", false] }, 1, 0] } }, + acceptedWithoutEdit: { $sum: { $cond: [{ $and: [{ $ifNull: ["$aiGeneratedRemark", false] }, { $eq: ["$isEdited", false] }] }, 1, 0] } }, + totalLength: { + $sum: { + $strLenCP: { $ifNull: ["$finalRemark", { $ifNull: ["$aiGeneratedRemark", ""] }] } + } + } + } + } + ]); + + const s = stats[0] || { total: 0, aiGenerated: 0, acceptedWithoutEdit: 0, totalLength: 0 }; + const adoptionPercent = s.aiGenerated > 0 ? Math.round((s.acceptedWithoutEdit / s.aiGenerated) * 100) : 0; + const avgLength = s.total > 0 ? Math.round(s.totalLength / s.total) : 0; + return { - aiSuggestions: 0, - acceptanceRate: 0, - manualAdjustments: 0, - changeFromPrevious: { suggestions: "+0", acceptance: "+0%" } + avgConclusionRemarkLength: avgLength, + aiSummaryAdoptionPercent: adoptionPercent, + totalWithConclusion: s.total, + aiGeneratedCount: s.aiGenerated, + manualCount: s.total - s.aiGenerated, + changeFromPrevious: { adoption: "+0%", length: "+0 chars" } }; } @@ -319,9 +502,9 @@ export class DashboardMongoService { engagement, aiInsights, dateRange: { - start: range.start, - end: range.end, - label: dateRange || 'last30days' + start: range?.start || null, + end: range?.end || null, + label: dateRange || 'all' } }; } @@ -336,8 +519,8 @@ export class DashboardMongoService { const match: any = { isDraft: false }; if (applyDateRange && range) { match.$or = [ - { 'dates.submission': { $gte: range.start, $lte: range.end } }, - { $and: [{ 'dates.submission': null }, { 'dates.created': { $gte: range.start, $lte: range.end } }] } + { submissionDate: { $gte: range.start, $lte: range.end } }, + { $and: [{ submissionDate: null }, { createdAt: { $gte: range.start, $lte: range.end } }] } ]; } @@ -346,23 +529,30 @@ export class DashboardMongoService { { $group: { _id: "$initiator.department", - total: { $sum: 1 }, + totalRequests: { $sum: 1 }, approved: { $sum: { $cond: [{ $eq: ["$status", "APPROVED"] }, 1, 0] } }, rejected: { $sum: { $cond: [{ $eq: ["$status", "REJECTED"] }, 1, 0] } }, - pending: { $sum: { $cond: [{ $in: ["$status", ["PENDING", "IN_PROGRESS"]] }, 1, 0] } } + inProgress: { $sum: { $cond: [{ $in: ["$status", ["PENDING", "IN_PROGRESS"]] }, 1, 0] } } } }, { $project: { department: { $ifNull: ["$_id", "Unknown"] }, - total: 1, + totalRequests: 1, approved: 1, rejected: 1, - pending: 1, + inProgress: 1, + approvalRate: { + $cond: [ + { $gt: ["$totalRequests", 0] }, + { $round: [{ $multiply: [{ $divide: ["$approved", "$totalRequests"] }, 100] }, 1] }, + 0 + ] + }, _id: 0 } }, - { $sort: { total: -1 } } + { $sort: { totalRequests: -1 } } ]); } @@ -376,23 +566,60 @@ export class DashboardMongoService { const match: any = { isDraft: false }; if (applyDateRange && range) { match.$or = [ - { 'dates.submission': { $gte: range.start, $lte: range.end } }, - { $and: [{ 'dates.submission': null }, { 'dates.created': { $gte: range.start, $lte: range.end } }] } + { submissionDate: { $gte: range.start, $lte: range.end } }, + { $and: [{ submissionDate: null }, { createdAt: { $gte: range.start, $lte: range.end } }] } ]; } return await WorkflowRequestModel.aggregate([ { $match: match }, { - $group: { - _id: "$priority", - count: { $sum: 1 } + $lookup: { + from: 'approval_levels', + localField: 'requestId', + foreignField: 'requestId', + as: 'levels' } }, { $project: { - priority: "$_id", - count: 1, + priority: 1, + status: 1, + totalTatHours: 1, + isBreached: { + $anyElementTrue: { + $map: { + input: "$levels", + as: "lvl", + in: "$$lvl.tat.isBreached" + } + } + } + } + }, + { + $group: { + _id: "$priority", + totalCount: { $sum: 1 }, + avgCycleTimeHours: { $avg: "$totalTatHours" }, + approvedCount: { $sum: { $cond: [{ $eq: ["$status", "APPROVED"] }, 1, 0] } }, + breachedCount: { $sum: { $cond: ["$isBreached", 1, 0] } } + } + }, + { + $project: { + priority: { $toLower: "$_id" }, + totalCount: 1, + avgCycleTimeHours: { $round: ["$avgCycleTimeHours", 1] }, + approvedCount: 1, + breachedCount: 1, + complianceRate: { + $cond: [ + { $gt: ["$totalCount", 0] }, + { $round: [{ $multiply: [{ $divide: [{ $subtract: ["$totalCount", "$breachedCount"] }, "$totalCount"] }, 100] }, 1] }, + 0 + ] + }, _id: 0 } } @@ -405,7 +632,23 @@ export class DashboardMongoService { async getRecentActivity(userId: string, page: number, limit: number, viewAsUser?: boolean) { const skip = (page - 1) * limit; + const user = await UserModel.findOne({ userId }); + const isAdmin = !viewAsUser && user?.role === 'ADMIN'; + + const match: any = {}; + + if (!isAdmin) { + // Find all requestIds where user is initiator or participant + const [initiatedRequests, participations] = await Promise.all([ + WorkflowRequestModel.find({ 'initiator.userId': userId }).distinct('requestId'), + ParticipantModel.find({ userId }).distinct('requestId') + ]); + const relatedRequestIds = Array.from(new Set([...initiatedRequests, ...participations])); + match.requestId = { $in: relatedRequestIds }; + } + const activities = await ActivityModel.aggregate([ + { $match: match }, { $sort: { createdAt: -1 } }, { $skip: skip }, { $limit: limit }, @@ -450,7 +693,7 @@ export class DashboardMongoService { } ]); - const total = await ActivityModel.countDocuments({}); + const total = await ActivityModel.countDocuments(match); return { activities, @@ -465,23 +708,105 @@ export class DashboardMongoService { * Get AI Remark Utilization metrics */ async getAIRemarkUtilization(userId: string, dateRange?: string, startDate?: string, endDate?: string) { + const applyDateRange = dateRange !== 'all' && dateRange !== undefined; + const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; + + const match: any = {}; + if (applyDateRange && range) match.createdAt = { $gte: range.start, $lte: range.end }; + + const stats = await ConclusionRemarkModel.aggregate([ + { $match: match }, + { + $group: { + _id: { $dateToString: { format: "%b", date: "$createdAt" } }, + usage: { $sum: { $cond: [{ $ifNull: ["$aiGeneratedRemark", false] }, 1, 0] } }, + edits: { $sum: { $cond: ["$isEdited", 1, 0] } }, + totalInMonth: { $sum: 1 } + } + }, + { $sort: { _id: 1 } } + ]); + + const totalUsage = stats.reduce((acc, curr) => acc + curr.usage, 0); + const totalEdits = stats.reduce((acc, curr) => acc + curr.edits, 0); + const totalOverall = stats.reduce((acc, curr) => acc + curr.totalInMonth, 0); + return { - totalRequests: 0, - aiRemarkCount: 0, - utilizationRate: 0, - trends: [] + totalUsage, + totalEdits, + editRate: totalUsage > 0 ? Math.round((totalEdits / totalUsage) * 100) : 0, + monthlyTrends: stats.map(s => ({ + month: s._id, + aiUsage: s.usage, + manualEdits: s.edits + })) }; } /** - * Get Approver Performance metrics + * Get Approver Performance metrics with pagination */ async getApproverPerformance(userId: string, dateRange: string | undefined, page: number, limit: number, startDate?: string, endDate?: string, priority?: string, slaCompliance?: string) { + const skip = (page - 1) * limit; + const applyDateRange = dateRange !== 'all' && dateRange !== undefined; + const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; + + const match: any = { status: { $in: ['APPROVED', 'REJECTED'] } }; + if (applyDateRange && range) match.actionDate = { $gte: range.start, $lte: range.end }; + if (priority) match.priority = priority.toUpperCase(); + + const performance = await ApprovalLevelModel.aggregate([ + { $match: match }, + { + $group: { + _id: "$approver.userId", + name: { $first: "$approver.name" }, + totalActions: { $sum: 1 }, + approved: { $sum: { $cond: [{ $eq: ["$status", "APPROVED"] }, 1, 0] } }, + rejected: { $sum: { $cond: [{ $eq: ["$status", "REJECTED"] }, 1, 0] } }, + avgTatUsage: { $avg: "$tat.percentageUsed" }, + breachedCount: { $sum: { $cond: ["$tat.isBreached", 1, 0] } } + } + }, + { + $project: { + approverId: "$_id", + approverName: "$name", + totalApproved: "$approved", + approvedCount: "$approved", + rejectedCount: "$rejected", + closedCount: { $literal: 0 }, + tatCompliancePercent: { + $cond: [ + { $gt: ["$totalActions", 0] }, + { $round: [{ $multiply: [{ $divide: [{ $subtract: ["$totalActions", "$breachedCount"] }, "$totalActions"] }, 100] }, 0] }, + 0 + ] + }, + avgResponseHours: { $round: [{ $divide: ["$avgTatUsage", 10] }, 3] }, + pendingCount: { $literal: 0 }, + withinTatCount: { $subtract: ["$totalActions", "$breachedCount"] }, + breachedCount: "$breachedCount" + } + }, + { $sort: { totalApproved: -1 } }, + { $skip: skip }, + { $limit: limit } + ]); + + const totalResult = await ApprovalLevelModel.aggregate([ + { $match: match }, + { $group: { _id: "$approver.userId" } }, + { $count: "total" } + ]); + + const total = totalResult[0]?.total || 0; + return { - performance: [], + performance, currentPage: page, - totalPages: 0, - totalRecords: 0, + totalPages: Math.ceil(total / limit), + totalRecords: total, limit }; } @@ -491,17 +816,116 @@ export class DashboardMongoService { */ async getCriticalRequests(userId: string, page: number, limit: number, viewAsUser?: boolean) { const skip = (page - 1) * limit; - const match: any = { priority: 'HIGH', status: { $in: ['PENDING', 'IN_PROGRESS'] } }; + const user = await UserModel.findOne({ userId }); + const isAdmin = !viewAsUser && user?.role === 'ADMIN'; - const criticalRequests = await WorkflowRequestModel.find(match) - .sort({ 'dates.created': -1 }) - .skip(skip) - .limit(limit); + const match: any = { + priority: 'EXPRESS', + status: { $in: ['PENDING', 'IN_PROGRESS'] }, + isDraft: false + }; + + if (!isAdmin) { + // "Related" = Initiator OR Participant (Involved) + const participations = await ParticipantModel.find({ userId }).distinct('requestId'); + match.$or = [ + { 'initiator.userId': userId }, + { 'requestId': { $in: participations } } + ]; + } + + const criticalRequests = await WorkflowRequestModel.aggregate([ + { $match: match }, + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLvl: "$currentLevel" }, + pipeline: [ + { $match: { $expr: { $and: [{ $eq: ["$requestId", "$$reqId"] }, { $eq: ["$levelNumber", "$$currLvl"] }] } } } + ], + as: 'active_step' + } + }, + { + $project: { + _id: 0, + requestId: 1, + requestNumber: 1, + title: 1, + priority: { $toLower: "$priority" }, + status: { $toLower: "$status" }, + currentLevel: 1, + totalLevels: { $ifNull: ["$totalLevels", 1] }, + submissionDate: 1, + originalTATHours: { $ifNull: [{ $arrayElemAt: ["$active_step.tat.assignedHours", 0] }, 0] }, + breachCount: { $cond: [{ $arrayElemAt: ["$active_step.tat.isBreached", 0] }, 1, 0] }, + isCritical: { $literal: true }, + department: { $ifNull: ["$initiator.department", "Unknown"] }, + approver: { $arrayElemAt: ["$active_step.approver.name", 0] }, + approverId: { $arrayElemAt: ["$active_step.approver.userId", 0] }, + approverEmail: { $arrayElemAt: ["$active_step.approver.email", 0] }, + activeStepTat: { $arrayElemAt: ["$active_step.tat", 0] }, + activeStepPaused: { $arrayElemAt: ["$active_step.paused", 0] }, + isActionable: { + $eq: [userId, { $arrayElemAt: ["$active_step.approver.userId", 0] }] + }, + requestRole: { + $switch: { + branches: [ + { + case: { $eq: [userId, { $arrayElemAt: ["$active_step.approver.userId", 0] }] }, + then: 'APPROVER' + }, + { + case: { $eq: [userId, "$initiator.userId"] }, + then: 'INITIATOR' + } + ], + default: 'PARTICIPANT' + } + }, + createdAt: 1 + } + } + ]); + + // Enhance with accurate SLA status using the utility + const enhancedRequests = await Promise.all(criticalRequests.map(async (req: any) => { + if (req.activeStepTat) { + // Map pause info correctly (Field name alignment with tatTimeUtils) + const pauseInfo = req.activeStepPaused ? { + isPaused: req.activeStepPaused.isPaused, + pausedAt: req.activeStepPaused.pausedAt, + pauseElapsedHours: req.activeStepPaused.elapsedHoursBeforePause, + pauseResumeDate: req.activeStepPaused.resumedAt + } : undefined; + + const sla = await calculateSLAStatus( + req.activeStepTat.startTime || req.createdAt, + req.activeStepTat.assignedHours || 0, + 'express', // Critical are express + null, + pauseInfo + ); + const rawRemaining = (req.activeStepTat.assignedHours || 0) - sla.elapsedHours; + return { + ...req, + totalTATHours: rawRemaining, + elapsedHours: sla.elapsedHours, + breachCount: sla.percentageUsed >= 100 ? 1 : 0, + breachTime: sla.percentageUsed >= 100 ? Math.abs(rawRemaining) : 0 + }; + } + return req; + })); const total = await WorkflowRequestModel.countDocuments(match); return { - criticalRequests, + criticalRequests: enhancedRequests, currentPage: page, totalPages: Math.ceil(total / limit), totalRecords: total, @@ -510,14 +934,122 @@ export class DashboardMongoService { } /** - * Get upcoming deadlines + * Get upcoming deadlines with pagination */ async getUpcomingDeadlines(userId: string, page: number, limit: number, viewAsUser?: boolean) { + const skip = (page - 1) * limit; + const user = await UserModel.findOne({ userId }); + const isAdmin = !viewAsUser && user?.role === 'ADMIN'; + + const match: any = { status: { $in: ['PENDING', 'IN_PROGRESS'] }, isDraft: false }; + + const pipeline: any[] = [{ $match: match }]; + + // Join with approval levels to get active step details + pipeline.push({ + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLvl: "$currentLevel" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$requestId", "$$reqId"] }, + { $eq: ["$levelNumber", "$$currLvl"] } + ] + } + } + } + ], + as: 'active_step' + } + }); + + // For non-admins, strict filter: must be the current active approver + if (!isAdmin) { + pipeline.push({ + $match: { + 'active_step.approver.userId': userId + } + }); + } + + pipeline.push( + { + $project: { + _id: 0, + levelId: { $arrayElemAt: ["$active_step.levelId", 0] }, + requestId: 1, + requestNumber: 1, + requestTitle: "$title", + levelNumber: { $arrayElemAt: ["$active_step.levelNumber", 0] }, + currentLevel: 1, + totalLevels: 1, + approverName: { $arrayElemAt: ["$active_step.approver.name", 0] }, + approverEmail: { $arrayElemAt: ["$active_step.approver.email", 0] }, + tatHours: { $ifNull: [{ $arrayElemAt: ["$active_step.tat.assignedHours", 0] }, 0] }, + activeStepTat: { $arrayElemAt: ["$active_step.tat", 0] }, + activeStepPaused: { $arrayElemAt: ["$active_step.paused", 0] }, + levelStartTime: { $ifNull: [{ $arrayElemAt: ["$active_step.tat.startTime", 0] }, "$createdAt"] }, + priority: { $toLower: "$priority" }, + status: { $toLower: "$status" }, + createdAt: 1, + submissionDate: 1 + } + } + ); + + pipeline.push({ + $facet: { + data: [ + { $sort: { levelStartTime: 1 } }, + { $skip: skip }, + { $limit: limit } + ], + total: [{ $count: "count" }] + } + }); + + const result = await WorkflowRequestModel.aggregate(pipeline); + const deadlines = result[0]?.data || []; + const totalResult = result[0]?.total[0]?.count || 0; + + // Enhance with accurate SLA status + const enhancedDeadlines = await Promise.all(deadlines.map(async (deadline: any) => { + if (deadline.activeStepTat) { + // Map pause info correctly + const pauseInfo = deadline.activeStepPaused ? { + isPaused: deadline.activeStepPaused.isPaused, + pausedAt: deadline.activeStepPaused.pausedAt, + pauseElapsedHours: deadline.activeStepPaused.elapsedHoursBeforePause, + pauseResumeDate: deadline.activeStepPaused.resumedAt + } : undefined; + + const sla = await calculateSLAStatus( + deadline.activeStepTat.startTime || deadline.levelStartTime, + deadline.activeStepTat.assignedHours || 0, + deadline.priority === 'express' ? 'express' : 'standard', + null, + pauseInfo + ); + const rawRemaining = (deadline.activeStepTat.assignedHours || 0) - sla.elapsedHours; + return { + ...deadline, + remainingHours: rawRemaining, + elapsedHours: sla.elapsedHours, + tatPercentageUsed: sla.percentageUsed, + breachTime: sla.percentageUsed >= 100 ? Math.abs(rawRemaining) : 0 + }; + } + return deadline; + })); + return { - deadlines: [], + deadlines: enhancedDeadlines, currentPage: page, - totalPages: 0, - totalRecords: 0, + totalPages: Math.ceil(totalResult / limit), + totalRecords: totalResult, limit }; } @@ -526,11 +1058,118 @@ export class DashboardMongoService { * Get Request Lifecycle Report */ async getLifecycleReport(userId: string, page: number, limit: number, dateRange?: string, startDate?: string, endDate?: string) { + const skip = (page - 1) * limit; + const applyDateRange = dateRange !== 'all' && dateRange !== undefined; + const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; + + const match: any = { isDraft: false }; + if (applyDateRange && range) { + match.createdAt = { $gte: range.start, $lte: range.end }; + } + + const lifecycleData = await WorkflowRequestModel.aggregate([ + { $match: match }, + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $lookup: { + from: 'approval_levels', + localField: 'requestId', + foreignField: 'requestId', + as: 'levels' + } + }, + { + $project: { + requestId: 1, + requestNumber: 1, + title: 1, + priority: 1, + status: 1, + submissionDate: { $ifNull: ["$submissionDate", "$createdAt"] }, + closureDate: 1, + currentLevel: 1, + totalLevels: 1, + createdAt: 1, + updatedAt: 1, + initiatorName: "$initiator.name", + initiatorEmail: "$initiator.email", + levels: 1 + } + }, + { + $addFields: { + currentLevelData: { + $arrayElemAt: [ + { + $filter: { + input: "$levels", + as: "lvl", + cond: { $eq: ["$$lvl.levelNumber", "$currentLevel"] } + } + }, + 0 + ] + }, + overallTATHours: { $ceil: { $sum: "$levels.tat.assignedHours" } }, + totalTATHours: { $ceil: { $sum: "$levels.tat.elapsedHours" } }, + breachCount: { + $size: { + $filter: { + input: "$levels", + as: "lvl", + cond: { $eq: ["$$lvl.tat.isBreached", true] } + } + } + } + } + }, + { + $project: { + requestId: 1, + requestNumber: 1, + title: 1, + priority: { $toLower: "$priority" }, + initiatorName: 1, + initiatorEmail: 1, + submissionDate: 1, + closureDate: 1, + currentLevel: 1, + totalLevels: 1, + currentStageName: { $ifNull: ["$currentLevelData.levelName", "Completed"] }, + currentApproverName: { $ifNull: ["$currentLevelData.approver.name", "N/A"] }, + overallTATHours: 1, + totalTATHours: 1, + breachCount: 1, + createdAt: 1, + updatedAt: 1, + status: { + $cond: [ + { $eq: ["$status", "APPROVED"] }, "Approved", + { + $cond: [ + { $eq: ["$status", "REJECTED"] }, "Rejected", + { + $cond: [ + { $gt: ["$breachCount", 0] }, "Delayed", + "In Progress" + ] + } + ] + } + ] + } + } + }]); + + const total = await WorkflowRequestModel.countDocuments(match); + return { - lifecycleData: [], + lifecycleData, currentPage: page, - totalPages: 0, - totalRecords: 0, + totalPages: Math.ceil(total / limit), + totalRecords: total, limit }; } @@ -551,7 +1190,9 @@ export class DashboardMongoService { // Apply date range if (dateRange && dateRange !== 'all') { const range = this.parseDateRange(dateRange, startDate, endDate); - match.createdAt = { $gte: range.start, $lte: range.end }; + if (range) { + match.createdAt = { $gte: range.start, $lte: range.end }; + } } const activities = await ActivityModel.aggregate([ @@ -619,14 +1260,80 @@ export class DashboardMongoService { } /** - * Get Workflow Aging Report + * Get Workflow Aging Report (how long at current level) */ async getWorkflowAgingReport(userId: string, threshold: number, page: number, limit: number, dateRange?: string, startDate?: string, endDate?: string) { + const skip = (page - 1) * limit; + const match: any = { status: { $in: ['PENDING', 'IN_PROGRESS'] }, isDraft: false }; + + const agingData = await WorkflowRequestModel.aggregate([ + { $match: match }, + { + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLvl: "$currentLevel" }, + pipeline: [ + { $match: { $expr: { $and: [{ $eq: ["$requestId", "$$reqId"] }, { $eq: ["$levelNumber", "$$currLvl"] }] } } } + ], + as: 'active_step' + } + }, + { + $project: { + requestId: 1, + requestNumber: 1, + title: 1, + currentLevel: 1, + initiator: 1, + createdAt: 1, + activeSince: { $arrayElemAt: ["$active_step.startTime", 0] }, + daysPending: { + $divide: [ + { $subtract: [new Date(), { $ifNull: [{ $arrayElemAt: ["$active_step.startTime", 0] }, "$createdAt"] }] }, + 1000 * 60 * 60 * 24 + ] + } + } + }, + { $match: { daysPending: { $gte: threshold } } }, + { $sort: { daysPending: -1 } }, + { $skip: skip }, + { $limit: limit } + ]); + + const totalResult = await WorkflowRequestModel.aggregate([ + { $match: match }, + { + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLvl: "$currentLevel" }, + pipeline: [ + { $match: { $expr: { $and: [{ $eq: ["$requestId", "$$reqId"] }, { $eq: ["$levelNumber", "$$currLvl"] }] } } } + ], + as: 'active_step' + } + }, + { + $project: { + daysPending: { + $divide: [ + { $subtract: [new Date(), { $ifNull: [{ $arrayElemAt: ["$active_step.startTime", 0] }, "$createdAt"] }] }, + 1000 * 60 * 60 * 24 + ] + } + } + }, + { $match: { daysPending: { $gte: threshold } } }, + { $count: "total" } + ]); + + const total = totalResult[0]?.total || 0; + return { - agingData: [], + agingData, currentPage: page, - totalPages: 0, - totalRecords: 0, + totalPages: Math.ceil(total / limit), + totalRecords: total, limit }; } @@ -635,12 +1342,71 @@ export class DashboardMongoService { * Get single approver stats */ async getSingleApproverStats(userId: string, approverId: string, dateRange?: string, startDate?: string, endDate?: string, priority?: string, slaCompliance?: string) { + const applyDateRange = dateRange !== 'all' && dateRange !== undefined; + const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; + + const match: any = { 'approver.userId': approverId }; + + // We need specialized counts. Facets are good here. + const stats = await ApprovalLevelModel.aggregate([ + { + $facet: { + actingStats: [ + { $match: match }, + { + $lookup: { + from: 'workflow_requests', + localField: 'requestId', + foreignField: 'requestId', + as: 'request' + } + }, + { $unwind: "$request" }, + { + $match: { + ...(priority && priority !== 'all' ? { 'request.priority': priority.toUpperCase() } : {}), + ...(applyDateRange && range ? { actionDate: { $gte: range.start, $lte: range.end } } : {}) + } + }, + { + $group: { + _id: null, + name: { $first: "$approver.name" }, + approved: { $sum: { $cond: [{ $eq: ["$status", "APPROVED"] }, 1, 0] } }, + rejected: { $sum: { $cond: [{ $eq: ["$status", "REJECTED"] }, 1, 0] } }, + totalActions: { $sum: { $cond: [{ $in: ["$status", ["APPROVED", "REJECTED"]] }, 1, 0] } }, + withinTat: { $sum: { $cond: [{ $and: [{ $in: ["$status", ["APPROVED", "REJECTED"]] }, { $eq: ["$tat.isBreached", false] }] }, 1, 0] } }, + breached: { $sum: { $cond: [{ $and: [{ $in: ["$status", ["APPROVED", "REJECTED"]] }, { $eq: ["$tat.isBreached", true] }] }, 1, 0] } }, + totalTatUsage: { $sum: { $cond: [{ $in: ["$status", ["APPROVED", "REJECTED"]] }, "$tat.percentageUsed", 0] } } + } + } + ], + pendingCount: [ + { $match: { 'approver.userId': approverId, status: { $in: ['PENDING', 'IN_PROGRESS'] } } }, + { $count: "count" } + ] + } + } + ]); + + const summary = stats[0].actingStats[0] || { name: 'Unknown', approved: 0, rejected: 0, totalActions: 0, withinTat: 0, breached: 0, totalTatUsage: 0 }; + const pending = stats[0].pendingCount[0]?.count || 0; + + const tatCompliance = summary.totalActions > 0 ? Math.round((summary.withinTat / summary.totalActions) * 100) : 0; + const avgTatUsage = summary.totalActions > 0 ? summary.totalTatUsage / summary.totalActions : 0; + return { - totalAssigned: 0, - completed: 0, - pending: 0, - avgTat: 0, - compliance: 0 + approverId, + approverName: summary.name, + totalApproved: summary.approved, + approvedCount: summary.approved, + rejectedCount: summary.rejected, + closedCount: 0, + pendingCount: pending, + withinTatCount: summary.withinTat, + breachedCount: summary.breached, + tatCompliancePercent: tatCompliance, + avgResponseHours: Number((avgTatUsage / 10).toFixed(3)) }; } @@ -648,11 +1414,96 @@ export class DashboardMongoService { * Get requests by approver */ async getRequestsByApprover(userId: string, approverId: string, page: number, limit: number, dateRange?: string, startDate?: string, endDate?: string, status?: string, priority?: string, slaCompliance?: string, search?: string) { + const skip = (page - 1) * limit; + + const match: any = { 'approver.userId': approverId }; + + const pipeline: any[] = [ + { $match: match }, + { + $lookup: { + from: 'workflow_requests', + localField: 'requestId', + foreignField: 'requestId', + as: 'request' + } + }, + { $unwind: "$request" } + ]; + + // Apply filters + const filters: any = {}; + if (priority && priority !== 'all') filters['request.priority'] = priority.toUpperCase(); + if (status && status !== 'all') { + const statusUpper = status.toUpperCase(); + if (statusUpper === 'PENDING') { + filters['request.status'] = { $in: ['PENDING', 'IN_PROGRESS', 'PAUSED'] }; + } else if (statusUpper === 'CLOSED') { + filters['request.status'] = 'CLOSED'; + } else { + filters['request.status'] = statusUpper; + } + } + if (slaCompliance && slaCompliance !== 'all') { + filters['tat.isBreached'] = slaCompliance === 'breached'; + } + if (search) { + filters.$or = [ + { 'request.requestNumber': { $regex: search, $options: 'i' } }, + { 'request.title': { $regex: search, $options: 'i' } } + ]; + } + + if (Object.keys(filters).length > 0) { + pipeline.push({ $match: filters }); + } + + // Sorting + pipeline.push({ $sort: { createdAt: -1 } }); + + // Facet for pagination + pipeline.push({ + $facet: { + data: [{ $skip: skip }, { $limit: limit }], + total: [{ $count: "count" }] + } + }); + + const results = await ApprovalLevelModel.aggregate(pipeline); + const data = results[0].data; + const total = results[0].total[0]?.count || 0; + + const mappedRequests = data.map((item: any) => ({ + requestId: item.request.requestId, + requestNumber: item.request.requestNumber, + title: item.request.title, + priority: item.request.priority.toLowerCase(), + status: item.request.status.toLowerCase(), + initiatorName: item.request.initiator.name, + initiatorEmail: item.request.initiator.email, + initiatorDepartment: item.request.initiator.department, + submissionDate: item.request.submissionDate, + closureDate: item.request.closureDate, + createdAt: item.request.createdAt, + updatedAt: item.request.updatedAt, + currentLevel: item.request.currentLevel, + totalLevels: item.request.totalLevels || 1, + levelId: item.levelId, + levelNumber: item.levelNumber, + approvalStatus: item.status.toLowerCase(), + approvalActionDate: item.actionDate, + slaStatus: item.tat.isBreached ? 'breached' : 'on_track', + levelTatHours: item.tat.assignedHours, + levelElapsedHours: Math.round(item.tat.elapsedHours), + isBreached: item.tat.isBreached, + totalTatHours: item.request.totalTatHours || 0 + })); + return { - requests: [], + requests: mappedRequests, currentPage: page, - totalPages: 0, - totalRecords: 0, + totalPages: Math.ceil(total / limit), + totalRecords: total, limit }; } diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 07f7f28..42c663d 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -609,7 +609,8 @@ export class DealerClaimMongoService { if (action === 'CANCEL') { // Update workflow status - workflow.status = WorkflowStatus.CANCELLED; // Make sure WorkflowStatus.CANCELLED exists or use 'CANCELLED' + workflow.status = 'REJECTED'; + workflow.workflowState = 'CLOSED'; workflow.isDeleted = true; // Soft delete or just mark cancelled? Usually cancelled. // Let's stick to status update. await workflow.save(); diff --git a/src/services/pause.service.ts b/src/services/pause.service.ts index 61978f8..9ed8d5e 100644 --- a/src/services/pause.service.ts +++ b/src/services/pause.service.ts @@ -118,6 +118,7 @@ export class PauseMongoService { workflow.pauseReason = reason; workflow.pauseResumeDate = resumeDate; workflow.status = 'PAUSED'; + workflow.workflowState = 'OPEN'; await workflow.save(); // Cancel jobs @@ -265,6 +266,7 @@ export class PauseMongoService { workflow.pauseReason = undefined; workflow.pauseResumeDate = undefined; workflow.status = 'IN_PROGRESS'; // Assuming previous status was IN_PROGRESS or PENDING + workflow.workflowState = 'OPEN'; await workflow.save(); // Cancel Resume Job diff --git a/src/services/summary.service.ts b/src/services/summary.service.ts index 73ee766..1939873 100644 --- a/src/services/summary.service.ts +++ b/src/services/summary.service.ts @@ -4,6 +4,7 @@ import { import logger from '../utils/logger'; import { v4 as uuidv4 } from 'uuid'; import mongoose from 'mongoose'; +import { UserService } from './user.service'; /** * Summary Service @@ -36,7 +37,7 @@ export class SummaryService { throw new Error('Request must be closed (APPROVED, REJECTED, or CLOSED) before creating summary'); } - const initiatorId = (workflow as any).initiatorId; + const initiatorId = (workflow as any).initiator?.userId || (workflow as any).initiatorId; const isInitiator = initiatorId === userId; const isAdmin = userRole && ['admin', 'super_admin', 'management'].includes(userRole.toLowerCase()); @@ -248,6 +249,8 @@ export class SummaryService { const summary = await RequestSummary.findOne({ summaryId }); if (!summary) throw new Error('Summary not found'); + const userService = new UserService(); + if (summary.initiatorId !== sharedBy) { throw new Error('Only the initiator can share this summary'); } @@ -265,6 +268,14 @@ export class SummaryService { // Try email const userByEmail = await User.findOne({ email: uid }); if (userByEmail) internalUserIds.push(userByEmail.userId); + else { + // Try fetching from Okta and creating (Auto-onboarding) + // uid might be Okta ID + const neUser = await userService.ensureOktaUserExists(uid); + if (neUser) { + internalUserIds.push(neUser.userId); + } + } } } diff --git a/src/services/user.service.ts b/src/services/user.service.ts index fd52e98..9f93a6d 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -468,4 +468,24 @@ export class UserService { return user; } + /** + * Ensure user exists by fetching from Okta ID (Auto-onboarding) + */ + async ensureOktaUserExists(oktaId: string): Promise { + try { + // 1. Fetch from Okta + const oktaUser = await this.fetchUserFromOktaById(oktaId); + if (!oktaUser) return null; + + // 2. Extract Data + const ssoData = extractOktaUserData(oktaUser); + if (!ssoData) return null; + + // 3. Create or Update in DB + return await this.createOrUpdateUser(ssoData); + } catch (error) { + logger.error(`[UserService] Failed to ensure Okta user exists for ID ${oktaId}`, error); + return null; + } + } } diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index 0a867f3..179b462 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -8,7 +8,7 @@ import logger from '../utils/logger'; import { notificationMongoService } from './notification.service'; import { activityMongoService } from './activity.service'; import { tatSchedulerMongoService } from './tatScheduler.service'; -import { addWorkingHours, addWorkingHoursExpress, calculateSLAStatus } from '../utils/tatTimeUtils'; +import { addWorkingHours, addWorkingHoursExpress, calculateSLAStatus, formatTime } from '../utils/tatTimeUtils'; const tatScheduler = tatSchedulerMongoService; @@ -132,6 +132,7 @@ export class WorkflowServiceMongo { description: workflowData.description, priority: workflowData.priority, status: 'DRAFT', + workflowState: 'DRAFT', currentLevel: 1, totalLevels: workflowData.approvalLevels.length, totalTatHours, @@ -157,7 +158,7 @@ export class WorkflowServiceMongo { }, tat: { assignedHours: level.tatHours, - assignedDays: Math.ceil(level.tatHours / 24), + assignedDays: Math.ceil(level.tatHours / 8), elapsedHours: 0, remainingHours: level.tatHours, percentageUsed: 0, @@ -922,6 +923,47 @@ export class WorkflowServiceMongo { throw error; } } + /** + * Parse date range string to Date objects + */ + private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): { start: Date; end: Date } | null { + if (dateRange === 'custom' && startDate && endDate) { + return { + start: dayjs(startDate).startOf('day').toDate(), + end: dayjs(endDate).endOf('day').toDate() + }; + } + + if (!dateRange || dateRange === 'all') return null; + + 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 quarterStartMonth = Math.floor(now.month() / 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: + // If it's not a known keyword, try parsing it as a number of days + const days = parseInt(dateRange, 10); + if (!isNaN(days)) { + return { + start: now.subtract(days, 'day').startOf('day').toDate(), + end: now.endOf('day').toDate() + }; + } + return null; + } + } async listWorkflows(page: number, limit: number, filters: any) { return this.listWorkflowsInternal(page, limit, filters, undefined, 'all'); @@ -952,21 +994,68 @@ export class WorkflowServiceMongo { const now = new Date(); // 1. Build Base Match Stage - const matchStage: any = { isDraft: false }; + const matchStage: any = { isDeleted: false }; + + // Handle Draft Visibility: + // - Allow drafts if specifically requested via status='DRAFT' + // - Allow drafts if viewing user's own 'initiated' requests + // - Otherwise, exclude drafts by default + if (filters.status && filters.status.toUpperCase() === 'DRAFT') { + matchStage.isDraft = true; + } else if (listType === 'initiated') { + // Initiated view shows both drafts and active requests + // Unless a specific status filter is already applied + if (!filters.status || filters.status === 'all') { + matchStage.isDraft = { $in: [true, false] }; + } else { + matchStage.isDraft = false; + } + } else { + matchStage.isDraft = false; + } if (filters.search) matchStage.$text = { $search: filters.search }; + + // 1.1 Handle Lifecycle Filter (Open vs Closed) + if (filters.lifecycle && filters.lifecycle !== 'all') { + const lifecycle = filters.lifecycle.toLowerCase(); + if (lifecycle === 'open') { + matchStage.workflowState = 'OPEN'; + } else if (lifecycle === 'closed') { + matchStage.workflowState = 'CLOSED'; + } + } + + // 1.2 Handle Outcome Status Filter if (filters.status && filters.status !== 'all') { const status = filters.status.toUpperCase(); if (status === 'PENDING') { + // Pending outcome usually means in-progress in the OPEN state matchStage.status = { $in: ['PENDING', 'IN_PROGRESS'] }; + } else if (status === 'DRAFT') { + matchStage.isDraft = true; + } else if (status === 'CLOSED') { + // "CLOSED" as a status is now deprecated in favor of Lifecycle Filter + // But if legacy code still sends it, we map it to CLOSED state + matchStage.workflowState = 'CLOSED'; } else { matchStage.status = status; } } if (filters.priority && filters.priority !== 'all') matchStage.priority = filters.priority.toUpperCase(); + if (filters.templateType && filters.templateType !== 'all') matchStage.templateType = filters.templateType.toUpperCase(); + if (filters.initiator) matchStage['initiator.userId'] = filters.initiator; if (filters.department && filters.department !== 'all') matchStage['initiator.department'] = filters.department; - if (filters.startDate && filters.endDate) { - matchStage['dates.created'] = { + + // Date Range Logic + const range = this.parseDateRange(filters.dateRange, filters.startDate, filters.endDate); + if (range) { + matchStage.createdAt = { + $gte: range.start, + $lte: range.end + }; + } else if (filters.startDate && filters.endDate) { + matchStage.createdAt = { $gte: new Date(filters.startDate), $lte: new Date(filters.endDate) }; @@ -1044,11 +1133,12 @@ export class WorkflowServiceMongo { } }); matchStage.$or = [ - { 'active_step.0.approver.userId': userId }, // Check first element of array + { 'active_step.0.approver.userId': userId }, { $and: [{ 'initiator.userId': userId }, { status: 'APPROVED' }] }, { $and: [{ 'membership.userId': userId }, { 'membership.participantType': 'SPECTATOR' }] } ]; - // Only show non-closed/non-rejected for "open for me" (except approved for initiator) + // Only show non-closed for "open for me" + matchStage.workflowState = { $ne: 'CLOSED' }; matchStage.status = { $in: ['PENDING', 'IN_PROGRESS', 'PAUSED', 'APPROVED'] }; } else if (listType === 'closed_by_me' && userId) { // Past approver or spectator AND status is CLOSED or REJECTED @@ -1061,7 +1151,10 @@ export class WorkflowServiceMongo { } }); matchStage['membership.userId'] = userId; - matchStage.status = { $in: ['CLOSED', 'REJECTED'] }; + matchStage.$or = [ + { workflowState: 'CLOSED' }, + { workflowState: { $exists: false }, status: { $in: ['CLOSED', 'REJECTED'] } } + ]; } // CRITICAL: Add match stage AFTER lookups so active_step and membership arrays exist @@ -1069,17 +1162,66 @@ export class WorkflowServiceMongo { // 3. Deep Filters (Approver Name, Level Status) if (filters.approverName) { - pipeline.push( - { - $lookup: { - from: 'approval_levels', - localField: 'requestId', // Join on UUID - foreignField: 'requestId', - as: 'matches_approvers' + const approverRegex = { $regex: filters.approverName, $options: 'i' }; + const approverMatch = { + $or: [ + { 'approver.name': approverRegex }, + { 'approver.userId': filters.approverName } + ] + }; + + if (filters.approverType === 'current') { + // Filter by CURRENT level approver name or ID + pipeline.push( + { + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLvl: "$currentLevel" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$requestId", "$$reqId"] }, + { $eq: ["$levelNumber", "$$currLvl"] } + ] + } + } + } + ], + as: 'current_step_filter' + } + }, + { + $match: { + $or: [ + { 'current_step_filter.approver.name': approverRegex }, + { 'current_step_filter.approver.userId': filters.approverName } + ] + } } - }, - { $match: { 'matches_approvers.approver.name': { $regex: filters.approverName, $options: 'i' } } } - ); + ); + } else { + // Search in ANY level (approverType === 'any' or undefined) + pipeline.push( + { + $lookup: { + from: 'approval_levels', + localField: 'requestId', // Join on UUID + foreignField: 'requestId', + as: 'matches_approvers' + } + }, + { + $match: { + $or: [ + { 'matches_approvers.approver.name': approverRegex }, + { 'matches_approvers.approver.userId': filters.approverName } + ] + } + } + ); + } } if (filters.levelStatus && filters.levelNumber) { @@ -1096,8 +1238,46 @@ export class WorkflowServiceMongo { ); } + if (filters.slaCompliance && filters.slaCompliance !== 'all') { + pipeline.push({ + $lookup: { + from: 'approval_levels', + let: { reqId: "$requestId", currLevelId: "$currentLevelId", currLvl: "$currentLevel" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$requestId", "$$reqId"] }, + { + $or: [ + { $eq: ["$levelId", "$$currLevelId"] }, + { + $and: [ + { $eq: [{ $type: "$$currLevelId" }, "missing"] }, + { $eq: ["$levelNumber", "$$currLvl"] } + ] + } + ] + } + ] + } + } + } + ], + as: 'active_sla_step' + } + }); + + if (filters.slaCompliance === 'breached') { + pipeline.push({ $match: { 'active_sla_step.tat.isBreached': true } }); + } else if (filters.slaCompliance === 'on_track') { + pipeline.push({ $match: { 'active_sla_step.tat.isBreached': false } }); + } + } + // 4. Sort & Pagination - const sortField = sortBy || 'dates.created'; + const sortField = sortBy || 'createdAt'; const sortDir = sortOrder?.toLowerCase() === 'asc' ? 1 : -1; pipeline.push( @@ -1127,6 +1307,7 @@ export class WorkflowServiceMongo { title: 1, description: 1, status: 1, + workflowState: 1, priority: 1, workflowType: 1, templateType: 1, @@ -1217,10 +1398,8 @@ export class WorkflowServiceMongo { deadline: deadline || null, isPaused: !!pauseInfo, status: currentStep.tat?.isBreached ? 'breached' : 'on_track', - remainingText: `${Math.floor(Math.max(0, assignedHours - elapsedHours))}h ${Math.round((Math.max(0, assignedHours - elapsedHours) % 1) * 60)}m`, - elapsedText: elapsedHours >= 24 - ? `${Math.floor(elapsedHours / 24)}d ${Math.floor(elapsedHours % 24)}h ${Math.round((elapsedHours % 1) * 60)}m` - : `${Math.floor(elapsedHours)}h ${Math.round((elapsedHours % 1) * 60)}m` + remainingText: formatTime(Math.max(0, assignedHours - elapsedHours)), + elapsedText: formatTime(elapsedHours) }; } catch (error) { logger.error('[listWorkflows] TAT calculation error:', error); @@ -1259,10 +1438,8 @@ export class WorkflowServiceMongo { deadline: workflowDeadline, isPaused: result.isPaused || false, status: requestPercentageUsed >= 100 ? 'breached' : 'on_track', - remainingText: `${Math.floor(requestRemainingHours)}h ${Math.round((requestRemainingHours % 1) * 60)}m`, - elapsedText: requestElapsedHours >= 24 - ? `${Math.floor(requestElapsedHours / 24)}d ${Math.floor(requestElapsedHours % 24)}h ${Math.round((requestElapsedHours % 1) * 60)}m` - : `${Math.floor(requestElapsedHours)}h ${Math.round((requestElapsedHours % 1) * 60)}m` + remainingText: formatTime(requestRemainingHours), + elapsedText: formatTime(requestElapsedHours) }; // Add currentApprover info (from currentStep if available) @@ -1296,7 +1473,7 @@ export class WorkflowServiceMongo { // 7. Total Count (Optimized) let total = 0; - const needsAggCount = !!(filters.approverName || (filters.levelStatus) || listType === 'my_requests' || listType === 'participant' || listType === 'open_for_me' || listType === 'closed_by_me'); + const needsAggCount = !!(filters.approverName || filters.levelStatus || filters.slaCompliance || listType === 'my_requests' || listType === 'participant' || listType === 'open_for_me' || listType === 'closed_by_me'); if (needsAggCount) { const countPipeline = [...pipeline].filter(s => !s.$sort && !s.$skip && !s.$limit && !s.$project && !s.$lookup || (s.$lookup && (s.$lookup.from === 'participants' || s.$lookup.from === 'approval_levels'))); @@ -1446,6 +1623,7 @@ export class WorkflowServiceMongo { description: requestObj.description, priority: requestObj.priority, status: requestObj.status, + workflowState: requestObj.workflowState || 'OPEN', currentLevel: requestObj.currentLevel, totalLevels: requestObj.totalLevels, totalTatHours: requestObj.totalTatHours?.toString() || '0.00', @@ -1575,10 +1753,8 @@ export class WorkflowServiceMongo { deadline: levelObj.tat?.endTime || null, isPaused: levelObj.paused?.isPaused || false, status: levelObj.tat?.isBreached ? 'breached' : 'on_track', - remainingText: `${Math.floor(remainingHours)}h ${Math.round((remainingHours % 1) * 60)}m`, - elapsedText: elapsedHours >= 24 - ? `${Math.floor(elapsedHours / 24)}d ${Math.floor(elapsedHours % 24)}h ${Math.round((elapsedHours % 1) * 60)}m` - : `${Math.floor(elapsedHours)}h ${Math.round((elapsedHours % 1) * 60)}m` + remainingText: formatTime(remainingHours), + elapsedText: formatTime(elapsedHours) } : null }; })); @@ -1618,10 +1794,8 @@ export class WorkflowServiceMongo { status: requestPercentageUsed >= 100 ? 'breached' : 'on_track', isPaused: requestObj.isPaused || false, // Use requestObj.isPaused for workflow level deadline: workflowDeadline, - elapsedText: requestElapsedHours >= 24 - ? `${Math.floor(requestElapsedHours / 24)}d ${Math.floor(requestElapsedHours % 24)}h ${Math.round((requestElapsedHours % 1) * 60)}m` - : `${Math.floor(requestElapsedHours)}h ${Math.round((requestElapsedHours % 1) * 60)}m`, - remainingText: `${Math.floor(requestRemainingHours)}h ${Math.round((requestRemainingHours % 1) * 60)}m` + elapsedText: formatTime(requestElapsedHours), + remainingText: formatTime(requestRemainingHours) }; } catch (error) { console.error('[getWorkflowDetails] Request-level TAT calculation error:', error); @@ -1649,8 +1823,8 @@ export class WorkflowServiceMongo { status: currentApprovalData.tatBreached ? 'breached' : 'on_track', // Corrected to 'on_track' isPaused: currentApprovalData.isPaused, deadline: currentApprovalData.levelEndTime || null, - elapsedText: `${Math.floor(currentApprovalData.elapsedHours)}h ${Math.round((currentApprovalData.elapsedHours % 1) * 60)}m`, - remainingText: `${Math.floor(currentApprovalData.remainingHours)}h ${Math.round((currentApprovalData.remainingHours % 1) * 60)}m` + elapsedText: formatTime(currentApprovalData.elapsedHours), + remainingText: formatTime(currentApprovalData.remainingHours) } : null) }; @@ -1712,6 +1886,7 @@ export class WorkflowServiceMongo { workflow.isDraft = false; workflow.status = 'PENDING'; + workflow.workflowState = 'OPEN'; workflow.submissionDate = new Date(); await workflow.save(); diff --git a/src/types/common.types.ts b/src/types/common.types.ts index 9f53982..f935645 100644 --- a/src/types/common.types.ts +++ b/src/types/common.types.ts @@ -9,8 +9,7 @@ export enum WorkflowStatus { APPROVED = 'APPROVED', REJECTED = 'REJECTED', CLOSED = 'CLOSED', - PAUSED = 'PAUSED', - CANCELLED = 'CANCELLED' + PAUSED = 'PAUSED' } export enum ApprovalStatus { diff --git a/src/utils/tatTimeUtils.ts b/src/utils/tatTimeUtils.ts index 6d0e550..ff768b3 100644 --- a/src/utils/tatTimeUtils.ts +++ b/src/utils/tatTimeUtils.ts @@ -540,21 +540,6 @@ export async function calculateSLAStatus( status = 'approaching'; } - // Format remaining time - const formatTime = (hours: number) => { - if (hours <= 0) return '0h'; - const days = Math.floor(hours / 8); // 8 working hours per day - const remainingHrs = Math.floor(hours % 8); - const minutes = Math.round((hours % 1) * 60); - - if (days > 0) { - return minutes > 0 - ? `${days}d ${remainingHrs}h ${minutes}m` - : `${days}d ${remainingHrs}h`; - } - return minutes > 0 ? `${remainingHrs}h ${minutes}m` : `${remainingHrs}h`; - }; - return { elapsedHours: Math.round(elapsedHours * 100) / 100, remainingHours: Math.round(remainingHours * 100) / 100, @@ -567,6 +552,27 @@ export async function calculateSLAStatus( }; } +/** + * Format hours into "X day(s) Y h Z min" string + * @param hours - Number of hours + * @returns Formatted string + */ +export const formatTime = (hours: number): string => { + if (hours <= 0) return '0h'; + const days = Math.floor(hours / 8); // 8 working hours per day + const remainingHrs = Math.floor(hours % 8); + const minutes = Math.round((hours % 1) * 60); + + const dayLabel = days === 1 ? 'day' : 'days'; + + if (days > 0) { + return minutes > 0 + ? `${days} ${dayLabel} ${remainingHrs}h ${minutes}min` + : `${days} ${dayLabel} ${remainingHrs}h`; + } + return minutes > 0 ? `${remainingHrs}h ${minutes}min` : `${remainingHrs}h`; +}; + /** * Calculate elapsed working hours between two dates * Uses minute-by-minute precision to accurately count only working time diff --git a/verify-dashboard-filtering.ts b/verify-dashboard-filtering.ts new file mode 100644 index 0000000..a3a00bd --- /dev/null +++ b/verify-dashboard-filtering.ts @@ -0,0 +1,85 @@ +import mongoose from 'mongoose'; +import * as dotenv from 'dotenv'; +import { dashboardMongoService } from './src/services/dashboard.service'; +import { UserModel } from './src/models/mongoose/User.schema'; +import { WorkflowRequestModel } from './src/models/mongoose/WorkflowRequest.schema'; + +dotenv.config(); + +async function verify() { + try { + await mongoose.connect(process.env.MONGO_URI!); + console.log('Connected to MongoDB'); + + // 1. Find an Admin and a Regular User + const adminUser = await UserModel.findOne({ role: 'ADMIN', isActive: true }); + const regularUser = await UserModel.findOne({ role: 'USER', isActive: true }); + + if (!adminUser || !regularUser) { + console.error('Could not find both Admin and Regular User for testing'); + // List some users to help debug + const users = await UserModel.find({ isActive: true }).limit(5); + console.log('Available users:', users.map(u => ({ email: u.email, role: u.role }))); + process.exit(1); + } + + console.log(`\nTesting with:`); + console.log(`Admin User: ${adminUser.email} (${adminUser.userId})`); + console.log(`Regular User: ${regularUser.email} (${regularUser.userId})`); + + // 2. Test getUpcomingDeadlines + console.log('\n--- Testing Upcoming Deadlines ---'); + const adminDeadlines = await dashboardMongoService.getUpcomingDeadlines(adminUser.userId, 1, 10, false); + const userDeadlines = await dashboardMongoService.getUpcomingDeadlines(regularUser.userId, 1, 10, true); + + console.log(`Admin count: ${adminDeadlines.totalRecords}`); + console.log(`User count: ${userDeadlines.totalRecords}`); + + if (userDeadlines.deadlines.length > 0) { + const first = userDeadlines.deadlines[0]; + console.log(`First User Deadline Approver: ${first.approverEmail} (User Email: ${regularUser.email})`); + if (first.approverEmail !== regularUser.email) { + console.warn('WARNING: Regular user sees a deadline they are not the approver for! (Wait, are they just a participant?)'); + // Check if they are actually the current approver + console.log('Actual Approver in data:', first.approverEmail); + } else { + console.log('SUCCESS: User only sees their own deadlines.'); + } + } else { + console.log('No deadlines found for regular user.'); + } + + // 3. Test getCriticalRequests + console.log('\n--- Testing Critical Requests ---'); + const adminCritical = await dashboardMongoService.getCriticalRequests(adminUser.userId, 1, 10, false); + const userCritical = await dashboardMongoService.getCriticalRequests(regularUser.userId, 1, 10, true); + + console.log(`Admin count: ${adminCritical.totalRecords}`); + console.log(`User count: ${userCritical.totalRecords}`); + + // 4. Test getRecentActivity + console.log('\n--- Testing Recent Activity ---'); + const adminActivity = await dashboardMongoService.getRecentActivity(adminUser.userId, 1, 10, false); + const userActivity = await dashboardMongoService.getRecentActivity(regularUser.userId, 1, 10, true); + + console.log(`Admin count: ${adminActivity.totalRecords}`); + console.log(`User count: ${userActivity.totalRecords}`); + + // 5. Test getRequestStats + console.log('\n--- Testing Request Stats ---'); + // userId, dateRange, startDate, endDate, status, priority, templateType, department, initiator, approver, approverType, search, slaCompliance, viewAsUser + const adminStats = await dashboardMongoService.getRequestStats(adminUser.userId, 'all', undefined, undefined, 'all', 'all', 'all', 'all', 'all', 'all', 'any', undefined, 'all', false); + const userStats = await dashboardMongoService.getRequestStats(regularUser.userId, 'all', undefined, undefined, 'all', 'all', 'all', 'all', 'all', 'all', 'any', undefined, 'all', true); + + console.log(`Admin Total Requests: ${adminStats.totalRequests}`); + console.log(`User Total (Involved): ${userStats.totalRequests}`); + + console.log('\nVerification Complete'); + } catch (error) { + console.error('Verification failed:', error); + } finally { + await mongoose.disconnect(); + } +} + +verify();