import { WorkflowRequest } from '@models/WorkflowRequest'; import { ApprovalLevel } from '@models/ApprovalLevel'; import { Participant } from '@models/Participant'; import { Activity } from '@models/Activity'; import { WorkNote } from '@models/WorkNote'; import { Document } from '@models/Document'; import { TatAlert } from '@models/TatAlert'; import { User } from '@models/User'; import { Op, QueryTypes } from 'sequelize'; import { sequelize } from '@config/database'; import dayjs from 'dayjs'; import logger from '@utils/logger'; import { calculateSLAStatus } from '@utils/tatTimeUtils'; interface DateRangeFilter { start: Date; end: Date; } export class DashboardService { /** * Build user-level filter clause that includes all requests where user is involved: * - As initiator (created the request) * - As approver (in any approval level) * - As participant/spectator * * @param workflowAlias - The alias used for workflow_requests table (e.g., 'wf') * @returns SQL clause to filter requests for user-level view */ private buildUserLevelFilter(workflowAlias: string = 'wf'): string { return ` AND ( ${workflowAlias}.initiator_id = :userId OR EXISTS ( SELECT 1 FROM approval_levels al_user WHERE al_user.request_id = ${workflowAlias}.request_id AND al_user.approver_id = :userId ) OR EXISTS ( SELECT 1 FROM participants p_user WHERE p_user.request_id = ${workflowAlias}.request_id AND p_user.user_id = :userId ) ) `; } /** * Parse date range string to Date objects */ private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): DateRangeFilter { // If custom date range is provided, use those dates if (dateRange === 'custom' && startDate && endDate) { const start = dayjs(startDate).startOf('day').toDate(); const end = dayjs(endDate).endOf('day').toDate(); // Ensure end date is not in the future const now = dayjs(); const actualEnd = end > now.toDate() ? now.endOf('day').toDate() : end; return { start, end: actualEnd }; } // If custom is selected but dates are not provided, default to last 30 days if (dateRange === 'custom' && (!startDate || !endDate)) { const now = dayjs(); return { start: now.subtract(30, 'day').startOf('day').toDate(), end: now.endOf('day').toDate() }; } const now = dayjs(); switch (dateRange) { case 'today': return { start: now.startOf('day').toDate(), end: now.endOf('day').toDate() }; case 'week': return { start: now.startOf('week').toDate(), end: now.endOf('week').toDate() }; case 'month': return { start: now.startOf('month').toDate(), end: now.endOf('month').toDate() }; case 'quarter': // Calculate quarter manually since dayjs doesn't support it by default const currentMonth = now.month(); const quarterStartMonth = Math.floor(currentMonth / 3) * 3; return { start: now.month(quarterStartMonth).startOf('month').toDate(), end: now.month(quarterStartMonth + 2).endOf('month').toDate() }; case 'year': return { start: now.startOf('year').toDate(), end: now.endOf('year').toDate() }; default: // Default to last 30 days (inclusive of today) return { start: now.subtract(30, 'day').startOf('day').toDate(), end: now.endOf('day').toDate() // Include full current day }; } } /** * Get all KPIs for dashboard */ async getKPIs(userId: string, dateRange?: string, startDate?: string, endDate?: string, viewAsUser?: boolean) { const range = this.parseDateRange(dateRange, startDate, endDate); // Run all KPI queries in parallel for performance const [ requestStats, tatEfficiency, approverLoad, engagement, aiInsights ] = await Promise.all([ this.getRequestStats(userId, dateRange, startDate, endDate, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, viewAsUser), this.getTATEfficiency(userId, dateRange, startDate, endDate, viewAsUser), this.getApproverLoad(userId, dateRange, startDate, endDate, viewAsUser), this.getEngagementStats(userId, dateRange, startDate, endDate, viewAsUser), this.getAIInsights(userId, dateRange, startDate, endDate, viewAsUser) ]); return { requestVolume: requestStats, tatEfficiency, approverLoad, engagement, aiInsights, dateRange: { start: range.start, end: range.end, label: dateRange || 'last30days' } }; } /** * Get request volume and status statistics */ async getRequestStats( userId: string, dateRange?: string, startDate?: string, endDate?: string, status?: string, priority?: string, templateType?: string, department?: string, initiator?: string, approver?: string, approverType?: 'current' | 'any', search?: string, slaCompliance?: string, viewAsUser?: boolean ) { // Check if date range should be applied // 'all' means no date filter - show all requests regardless of date const applyDateRange = dateRange !== undefined && dateRange !== null && dateRange !== 'all'; const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null; // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // Build filter conditions let filterConditions = ''; const replacements: any = { userId }; // Add date range to replacements if date range is applied if (applyDateRange && range) { replacements.start = range.start; replacements.end = range.end; } // Status filter if (status && status !== 'all') { const statusUpper = status.toUpperCase(); if (statusUpper === 'PENDING') { // Pending includes both PENDING and IN_PROGRESS filterConditions += ` AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS')`; // IN_PROGRESS legacy support } else if (statusUpper === 'CLOSED') { filterConditions += ` AND wf.status = 'CLOSED'`; } else if (statusUpper === 'REJECTED') { filterConditions += ` AND wf.status = 'REJECTED'`; } else if (statusUpper === 'APPROVED') { filterConditions += ` AND wf.status = 'APPROVED'`; } else { // Fallback: use the uppercase value as-is filterConditions += ` AND wf.status = :status`; replacements.status = statusUpper; } } // Priority filter if (priority && priority !== 'all') { filterConditions += ` AND wf.priority = :priority`; replacements.priority = priority.toUpperCase(); } // TemplateType filter if (templateType && templateType !== 'all') { const templateTypeUpper = templateType.toUpperCase(); if (templateTypeUpper === 'CUSTOM') { // For CUSTOM, include both CUSTOM and null (legacy requests) filterConditions += ` AND (wf.template_type = 'CUSTOM' OR wf.template_type IS NULL)`; } else { filterConditions += ` AND wf.template_type = :templateType`; replacements.templateType = templateTypeUpper; } } // Department filter (through initiator) if (department && department !== 'all') { filterConditions += ` AND EXISTS ( SELECT 1 FROM users u WHERE u.user_id = wf.initiator_id AND u.department = :department )`; replacements.department = department; } // Initiator filter if (initiator && initiator !== 'all') { filterConditions += ` AND wf.initiator_id = :initiatorId`; replacements.initiatorId = initiator; } // Search filter (title, description, or requestNumber) if (search && search.trim()) { filterConditions += ` AND ( wf.title ILIKE :search OR wf.description ILIKE :search OR wf.request_number ILIKE :search )`; replacements.search = `%${search.trim()}%`; } // Approver filter (with current vs any logic) if (approver && approver !== 'all') { const approverTypeValue = approverType || 'current'; if (approverTypeValue === 'current') { // Filter by current active approver only filterConditions += ` AND EXISTS ( SELECT 1 FROM approval_levels al WHERE al.request_id = wf.request_id AND al.approver_id = :approverId AND al.status IN ('PENDING', 'IN_PROGRESS') AND al.level_number = wf.current_level )`; } else { // Filter by any approver (past or current) filterConditions += ` AND EXISTS ( SELECT 1 FROM approval_levels al WHERE al.request_id = wf.request_id AND al.approver_id = :approverId )`; } replacements.approverId = approver; } // SLA Compliance filter if (slaCompliance && slaCompliance !== 'all') { if (slaCompliance === 'breached') { filterConditions += ` AND EXISTS ( SELECT 1 FROM tat_alerts ta INNER JOIN approval_levels al ON ta.level_id = al.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true )`; } else if (slaCompliance === 'compliant') { // Compliant: completed requests that are not breached filterConditions += ` AND wf.status IN ('APPROVED', 'REJECTED', 'CLOSED') AND NOT EXISTS ( SELECT 1 FROM tat_alerts ta INNER JOIN approval_levels al ON ta.level_id = al.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true )`; } // Note: on_track, approaching, critical are calculated dynamically // For stats, we only filter by breached/compliant as these are stored in DB } // Organization Level: Admin/Management see ALL requests across organization // Personal Level: Regular users see requests where they are INVOLVED (initiator, approver, or participant) // Note: If dateRange is provided, filter by submission_date (or createdAt if submission_date is null). Otherwise, show all requests. // For pending/open requests, if no date range, count ALL pending requests regardless of creation date // For approved/rejected/closed, if date range is provided, count only those submitted in date range // Match the same logic as listParticipantRequests: include requests where submission_date is in range OR (submission_date is null AND created_at is in range) const dateFilterClause = applyDateRange ? `( (wf.submission_date BETWEEN :start AND :end AND wf.submission_date IS NOT NULL) OR (wf.submission_date IS NULL AND wf.created_at BETWEEN :start AND :end) )` : `1=1`; // No date filter - show all requests // Build user-level filter: Include requests where user is initiator, approver, or participant const userLevelFilter = !isAdmin ? ` AND ( wf.initiator_id = :userId OR EXISTS ( SELECT 1 FROM approval_levels al_user WHERE al_user.request_id = wf.request_id AND al_user.approver_id = :userId ) OR EXISTS ( SELECT 1 FROM participants p_user WHERE p_user.request_id = wf.request_id AND p_user.user_id = :userId ) ) ` : ''; let whereClauseForAllRequests = ` WHERE ${dateFilterClause} AND wf.is_draft = false AND (wf.is_deleted IS NULL OR wf.is_deleted = false) ${userLevelFilter} ${filterConditions} `; // For pending requests, if no date range is applied, don't filter by date at all // This ensures pending requests are always counted regardless of submission date // Match the same logic as listParticipantRequests: include requests where submission_date is in range OR (submission_date is null AND created_at is in range) const pendingDateFilterClause = applyDateRange ? `( (wf.submission_date BETWEEN :start AND :end AND wf.submission_date IS NOT NULL) OR (wf.submission_date IS NULL AND wf.created_at BETWEEN :start AND :end) )` : `1=1`; // No date filter for pending requests let whereClauseForPending = ` WHERE ${pendingDateFilterClause} AND wf.is_draft = false AND (wf.is_deleted IS NULL OR wf.is_deleted = false) AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS') ${userLevelFilter} ${filterConditions.replace(/AND \(wf\.status = 'PENDING' OR wf\.status = 'IN_PROGRESS'\)|AND wf\.status = 'PENDING'|AND wf\.status = 'IN_PROGRESS'/g, '').trim()} `; // Clean up any double ANDs whereClauseForPending = whereClauseForPending.replace(/\s+AND\s+AND/g, ' AND'); // Get total, approved, rejected, closed, and paused requests // If date range is applied, only count requests submitted in that range // If no date range, count all requests matching other filters const result = await sequelize.query(` SELECT COUNT(*)::int AS total_requests, COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_requests, COUNT(CASE WHEN wf.status = 'REJECTED' THEN 1 END)::int AS rejected_requests, COUNT(CASE WHEN wf.status = 'CLOSED' THEN 1 END)::int AS closed_requests, COUNT(CASE WHEN wf.is_paused = true THEN 1 END)::int AS paused_requests FROM workflow_requests wf ${whereClauseForAllRequests} `, { replacements, type: QueryTypes.SELECT }); // Get ALL pending/open requests (excluding paused) // Organization Level (Admin): All pending requests across organization // Personal Level (Regular User): Only pending requests they initiated // If no date range, count all pending requests regardless of submission date const pendingWhereClause = whereClauseForPending.replace( /AND \(wf\.status = 'PENDING' OR wf\.status = 'IN_PROGRESS'\)/, `AND (wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS') AND (wf.is_paused IS NULL OR wf.is_paused = false)` ); const pendingResult = await sequelize.query(` SELECT COUNT(*)::int AS open_requests FROM workflow_requests wf ${pendingWhereClause} `, { replacements, type: QueryTypes.SELECT }); // Get draft count separately (with filters) // For user-level, drafts are only visible to the initiator (not to approvers/participants) let draftWhereClause = `WHERE wf.is_draft = true ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} ${filterConditions}`; const draftResult = await sequelize.query(` SELECT COUNT(*)::int AS draft_count FROM workflow_requests wf ${draftWhereClause} `, { replacements, type: QueryTypes.SELECT }); const stats = result[0] as any; const pending = (pendingResult[0] as any); const drafts = (draftResult[0] as any); return { totalRequests: stats.total_requests || 0, openRequests: pending.open_requests || 0, // All pending requests regardless of creation date (excluding paused) approvedRequests: stats.approved_requests || 0, rejectedRequests: stats.rejected_requests || 0, closedRequests: stats.closed_requests || 0, pausedRequests: stats.paused_requests || 0, draftRequests: drafts.draft_count || 0, changeFromPrevious: { total: '+0', open: '+0', approved: '+0', rejected: '+0' } }; } /** * Get TAT efficiency metrics */ async getTATEfficiency(userId: string, dateRange?: string, startDate?: string, endDate?: string, viewAsUser?: boolean) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // For regular users: only their initiated requests // For admin: all requests // Include only CLOSED requests (ignore APPROVED and REJECTED) // CLOSED status represents requests that were finalized with a conclusion remark // This ensures we capture all requests that finished during the period, regardless of when they started let whereClause = ` WHERE wf.status = 'CLOSED' AND wf.is_draft = false AND wf.submission_date IS NOT NULL AND ( (wf.closure_date IS NOT NULL AND wf.closure_date BETWEEN :start AND :end) OR (wf.closure_date IS NULL AND wf.updated_at BETWEEN :start AND :end) ) ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; // Get closed requests with their submission and closure dates const completedRequests = await sequelize.query(` SELECT wf.request_id, wf.submission_date, wf.closure_date, wf.updated_at, wf.priority FROM workflow_requests wf ${whereClause} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); // Calculate cycle time using working hours for each request, grouped by priority const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); const priorityCycleTimes = new Map(); logger.info(`[Dashboard] Calculating cycle time for ${completedRequests.length} closed requests`); for (const req of completedRequests as any) { const submissionDate = req.submission_date; // Use closure_date if available, otherwise use updated_at const completionDate = req.closure_date || req.updated_at; const priority = (req.priority || 'STANDARD').toLowerCase(); let elapsedHours: number | null = null; if (submissionDate && completionDate) { try { // Calculate elapsed working hours (respects working hours, weekends, holidays) elapsedHours = await calculateElapsedWorkingHours( submissionDate, completionDate, priority ); // Group by priority if (!priorityCycleTimes.has(priority)) { priorityCycleTimes.set(priority, []); } priorityCycleTimes.get(priority)!.push(elapsedHours); logger.info(`[Dashboard] Request ${req.request_id} (${priority}): ${elapsedHours.toFixed(2)}h (submission: ${submissionDate}, completion: ${completionDate})`); } catch (error) { logger.error(`[Dashboard] Error calculating cycle time for request ${req.request_id}:`, error); } } else { logger.warn(`[Dashboard] Skipping request ${req.request_id} - missing dates (submission: ${submissionDate}, completion: ${completionDate})`); } // Note: Breach checking is now done in the allRequestsBreached loop below // using the same calculateSLAStatus logic as the Requests screen // This ensures consistency between Dashboard and All Requests screen } // Calculate average per priority const expressCycleTimes = priorityCycleTimes.get('express') || []; const standardCycleTimes = priorityCycleTimes.get('standard') || []; const expressAvg = expressCycleTimes.length > 0 ? Math.round((expressCycleTimes.reduce((sum, hours) => sum + hours, 0) / expressCycleTimes.length) * 100) / 100 : 0; const standardAvg = standardCycleTimes.length > 0 ? Math.round((standardCycleTimes.reduce((sum, hours) => sum + hours, 0) / standardCycleTimes.length) * 100) / 100 : 0; // Calculate overall average as average of EXPRESS and STANDARD averages // This is the average of the two priority averages (not weighted by count) let avgCycleTimeHours = 0; if (expressAvg > 0 && standardAvg > 0) { avgCycleTimeHours = Math.round(((expressAvg + standardAvg) / 2) * 100) / 100; } else if (expressAvg > 0) { avgCycleTimeHours = expressAvg; } else if (standardAvg > 0) { avgCycleTimeHours = standardAvg; } logger.info(`[Dashboard] Cycle time calculation: EXPRESS=${expressAvg.toFixed(2)}h (${expressCycleTimes.length} requests), STANDARD=${standardAvg.toFixed(2)}h (${standardCycleTimes.length} requests), Overall=${avgCycleTimeHours.toFixed(2)}h`); // Count ALL requests (pending, in-progress, approved, rejected, closed) that have currently breached TAT // Use the same logic as Requests screen: check currentLevelSLA status using calculateSLAStatus // This ensures delayedWorkflows matches what users see when filtering for "breached" in All Requests screen // For date range: completed requests (APPROVED/REJECTED/CLOSED) must be completed in date range // For pending/in-progress: include ALL pending/in-progress regardless of submission date (same as requestVolume stats) const allRequestsBreachedQuery = ` SELECT DISTINCT wf.request_id, wf.status, wf.priority, wf.current_level, al.level_start_time AS current_level_start_time, al.tat_hours AS current_level_tat_hours, wf.submission_date, wf.total_tat_hours, wf.closure_date, wf.updated_at FROM workflow_requests wf LEFT JOIN approval_levels al ON al.request_id = wf.request_id AND al.level_number = wf.current_level AND (al.status = 'IN_PROGRESS' OR (wf.status = 'CLOSED' AND al.status = 'APPROVED')) WHERE wf.is_draft = false AND wf.submission_date IS NOT NULL AND ( -- Completed requests: must be CLOSED in date range (ignore APPROVED and REJECTED) (wf.status = 'CLOSED' AND ( (wf.closure_date IS NOT NULL AND wf.closure_date BETWEEN :start AND :end) OR (wf.closure_date IS NULL AND wf.updated_at BETWEEN :start AND :end) )) -- Pending/in-progress: include ALL regardless of submission date OR wf.status IN ('PENDING', 'IN_PROGRESS') ) ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} AND ( EXISTS ( SELECT 1 FROM tat_alerts ta INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true AND al_breach.level_number = wf.current_level ) OR al.level_start_time IS NOT NULL OR wf.total_tat_hours > 0 ) `; const allRequestsBreached = await sequelize.query(allRequestsBreachedQuery, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); // Use calculateSLAStatus to check if each request is breached (same as Requests screen logic) const { calculateSLAStatus } = await import('@utils/tatTimeUtils'); let pendingBreachedCount = 0; // Also need to recalculate breachedCount for completed requests using same logic as Requests screen // This ensures we catch any completed requests that breached but weren't detected by previous checks let recalculatedBreachedCount = 0; let recalculatedCompliantCount = 0; for (const req of allRequestsBreached as any) { const isCompleted = req.status === 'CLOSED'; // Check current level SLA (same logic as Requests screen) let isBreached = false; if (req.current_level_start_time && req.current_level_tat_hours > 0) { try { const priority = (req.priority || 'standard').toLowerCase(); const levelEndDate = req.closure_date || null; // Use closure date if completed const slaData = await calculateSLAStatus(req.current_level_start_time, req.current_level_tat_hours, priority, levelEndDate); // Mark as breached if percentageUsed >= 100 (same as Requests screen) if (slaData.percentageUsed >= 100) { isBreached = true; } } catch (error) { logger.error(`[Dashboard] Error calculating SLA for request ${req.request_id}:`, error); } } // Also check overall SLA if current level SLA check doesn't show breach if (!isBreached && req.submission_date && req.total_tat_hours > 0) { try { const priority = (req.priority || 'standard').toLowerCase(); const overallEndDate = req.closure_date || null; const overallSLA = await calculateSLAStatus(req.submission_date, req.total_tat_hours, priority, overallEndDate); if (overallSLA.percentageUsed >= 100) { isBreached = true; } } catch (error) { logger.error(`[Dashboard] Error calculating overall SLA for request ${req.request_id}:`, error); } } if (isBreached) { if (isCompleted) { recalculatedBreachedCount++; } else { pendingBreachedCount++; } } else if (isCompleted) { // Count as compliant if completed and not breached recalculatedCompliantCount++; } } // Use recalculated counts which match Requests screen logic exactly // These counts use the same calculateSLAStatus logic as the Requests screen const finalBreachedCount = recalculatedBreachedCount; // Total delayed workflows = completed breached + currently pending/in-progress breached const totalDelayedWorkflows = finalBreachedCount + pendingBreachedCount; // Compliant workflows = all CLOSED requests that did NOT breach TAT // This includes: // - Closed requests that were closed within TAT // Use recalculated compliant count from above which uses same logic as Requests screen // Note: Only counting CLOSED requests now (APPROVED and REJECTED are ignored) const totalCompleted = recalculatedBreachedCount + recalculatedCompliantCount; const compliantCount = recalculatedCompliantCount; // Compliance percentage = (compliant / (total completed + pending breached)) * 100 // This shows health of the system: successful completions vs (failed completions + currently failing) // We include pending breached requests because they are already failures regarding SLA const totalFailuresAndSuccesses = totalCompleted + pendingBreachedCount; const compliancePercent = totalFailuresAndSuccesses > 0 ? Math.round((compliantCount / totalFailuresAndSuccesses) * 100) : 0; // Average cycle time is already calculated above from priority averages logger.info(`[Dashboard] Compliance calculation: ${totalCompleted} total completed (CLOSED), ${finalBreachedCount} completed breached, ${pendingBreachedCount} pending breached`); logger.info(`[Dashboard] Total Evaluated: ${totalFailuresAndSuccesses}, Compliant: ${compliantCount}, Score: ${compliancePercent}%`); logger.info(`[Dashboard] Breached requests (using Requests screen logic): ${finalBreachedCount} completed breached + ${pendingBreachedCount} pending/in-progress breached = ${totalDelayedWorkflows} total delayed`); return { avgTATCompliance: compliancePercent, avgCycleTimeHours, avgCycleTimeDays: Math.round((avgCycleTimeHours / 8) * 10) / 10, // 8 working hours per day delayedWorkflows: totalDelayedWorkflows, // Includes both completed and pending/in-progress breached requests totalCompleted, compliantWorkflows: compliantCount, changeFromPrevious: { compliance: '+5.8%', // TODO: Calculate actual change cycleTime: '-0.5h' } }; } /** * Get approver load statistics */ async getApproverLoad(userId: string, dateRange?: string, startDate?: string, endDate?: string, viewAsUser?: boolean) { const range = this.parseDateRange(dateRange, startDate, endDate); // Get pending actions where user is the CURRENT active approver // This means: the request is at this user's level AND it's the current level // Note: getApproverLoad is always user-specific (shows user's own pending/completed), so viewAsUser doesn't change behavior const pendingResult = await sequelize.query(` SELECT COUNT(DISTINCT al.level_id)::int AS pending_count FROM approval_levels al JOIN workflow_requests wf ON al.request_id = wf.request_id WHERE al.approver_id = :userId AND al.status = 'IN_PROGRESS' AND wf.status IN ('PENDING', 'IN_PROGRESS') AND wf.is_draft = false AND al.level_number = wf.current_level `, { replacements: { userId }, type: QueryTypes.SELECT }); // Get completed approvals // completed_today should always be TODAY regardless of date range filter // completed_this_week should be this week (Monday to Sunday) // IMPORTANT: Only count approvals where the user is the approver (al.approver_id = userId) const todayStart = dayjs().startOf('day').toDate(); const todayEnd = dayjs().endOf('day').toDate(); const weekStart = dayjs().startOf('week').toDate(); const weekEnd = dayjs().endOf('week').toDate(); const completedResult = await sequelize.query(` SELECT COUNT(CASE WHEN al.action_date >= :todayStart AND al.action_date <= :todayEnd THEN 1 END)::int AS completed_today, COUNT(CASE WHEN al.action_date >= :weekStart AND al.action_date <= :weekEnd THEN 1 END)::int AS completed_this_week FROM approval_levels al WHERE al.approver_id = :userId AND al.status IN ('APPROVED', 'REJECTED') AND al.action_date IS NOT NULL `, { replacements: { userId, todayStart, todayEnd, weekStart, weekEnd }, type: QueryTypes.SELECT }); const pending = (pendingResult[0] as any); const completed = (completedResult[0] as any); return { pendingActions: pending.pending_count || 0, completedToday: completed.completed_today || 0, completedThisWeek: completed.completed_this_week || 0, changeFromPrevious: { pending: '+2', completed: '+15%' } }; } /** * Get engagement and quality metrics */ async getEngagementStats(userId: string, dateRange?: string, startDate?: string, endDate?: string, viewAsUser?: boolean) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // Get work notes count - uses created_at // For regular users: only from requests they initiated let workNotesWhereClause = ` WHERE wn.created_at BETWEEN :start AND :end ${!isAdmin ? `AND EXISTS ( SELECT 1 FROM workflow_requests wf WHERE wf.request_id = wn.request_id AND wf.initiator_id = :userId AND wf.is_draft = false )` : ''} `; const workNotesResult = await sequelize.query(` SELECT COUNT(*)::int AS work_notes_count FROM work_notes wn ${workNotesWhereClause} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); // Get documents count - uses uploaded_at // For regular users: only from requests they initiated let documentsWhereClause = ` WHERE d.uploaded_at BETWEEN :start AND :end ${!isAdmin ? `AND EXISTS ( SELECT 1 FROM workflow_requests wf WHERE wf.request_id = d.request_id AND wf.initiator_id = :userId AND wf.is_draft = false )` : ''} `; const documentsResult = await sequelize.query(` SELECT COUNT(*)::int AS documents_count FROM documents d ${documentsWhereClause} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); const workNotes = (workNotesResult[0] as any); const documents = (documentsResult[0] as any); return { workNotesAdded: workNotes.work_notes_count || 0, attachmentsUploaded: documents.documents_count || 0, changeFromPrevious: { workNotes: '+25', attachments: '+8' } }; } /** * Get AI insights and closure metrics */ async getAIInsights(userId: string, dateRange?: string, startDate?: string, endDate?: string, viewAsUser?: boolean) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // For regular users: only their initiated requests // Use submission_date instead of created_at to filter by actual submission date let whereClause = ` WHERE wf.submission_date BETWEEN :start AND :end AND wf.status = 'APPROVED' AND wf.conclusion_remark IS NOT NULL AND wf.is_draft = false AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; const result = await sequelize.query(` SELECT COUNT(*)::int AS total_with_conclusion, AVG(LENGTH(wf.conclusion_remark))::numeric AS avg_remark_length, COUNT(CASE WHEN wf.ai_generated_conclusion IS NOT NULL AND wf.ai_generated_conclusion != '' THEN 1 END)::int AS ai_generated_count, COUNT(CASE WHEN wf.ai_generated_conclusion IS NULL OR wf.ai_generated_conclusion = '' THEN 1 END)::int AS manual_count FROM workflow_requests wf ${whereClause} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); const stats = result[0] as any; const totalWithConclusion = stats.total_with_conclusion || 0; const aiCount = stats.ai_generated_count || 0; const aiAdoptionPercent = totalWithConclusion > 0 ? Math.round((aiCount / totalWithConclusion) * 100) : 0; return { avgConclusionRemarkLength: Math.round(parseFloat(stats.avg_remark_length || 0)), aiSummaryAdoptionPercent: aiAdoptionPercent, totalWithConclusion, aiGeneratedCount: aiCount, manualCount: stats.manual_count || 0, changeFromPrevious: { adoption: '+12%', length: '+50 chars' } }; } /** * Get AI Remark Utilization with monthly trends */ async getAIRemarkUtilization(userId: string, dateRange?: string, startDate?: string, endDate?: string) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests const userFilter = !isAdmin ? `AND cr.edited_by = :userId` : ''; // Get overall metrics const overallMetrics = await sequelize.query(` SELECT COUNT(*)::int AS total_usage, COUNT(CASE WHEN cr.is_edited = true THEN 1 END)::int AS total_edits, ROUND( (COUNT(CASE WHEN cr.is_edited = true THEN 1 END)::numeric / NULLIF(COUNT(*)::numeric, 0)) * 100, 0 )::int AS edit_rate FROM conclusion_remarks cr WHERE cr.generated_at BETWEEN :start AND :end ${userFilter} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); // Get monthly trends (last 7 months) const monthlyTrends = await sequelize.query(` SELECT TO_CHAR(DATE_TRUNC('month', cr.generated_at), 'Mon') AS month, EXTRACT(MONTH FROM cr.generated_at)::int AS month_num, COUNT(*)::int AS ai_usage, COUNT(CASE WHEN cr.is_edited = true THEN 1 END)::int AS manual_edits FROM conclusion_remarks cr WHERE cr.generated_at >= NOW() - INTERVAL '7 months' ${userFilter} GROUP BY month, month_num ORDER BY month_num ASC `, { replacements: { userId }, type: QueryTypes.SELECT }); const stats = overallMetrics[0] as any; return { totalUsage: stats.total_usage || 0, totalEdits: stats.total_edits || 0, editRate: stats.edit_rate || 0, monthlyTrends: monthlyTrends.map((m: any) => ({ month: m.month, aiUsage: m.ai_usage, manualEdits: m.manual_edits })) }; } /** * Get Approver Performance metrics with pagination * Supports priority and SLA filters for stats calculation */ async getApproverPerformance( userId: string, dateRange?: string, page: number = 1, limit: number = 10, startDate?: string, endDate?: string, priority?: string, slaCompliance?: string ) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // For regular users: return empty (only admins should see this) if (!isAdmin) { return { performance: [], currentPage: page, totalPages: 0, totalRecords: 0, limit }; } // Calculate offset const offset = (page - 1) * limit; // Build filter conditions const replacements: any = { start: range.start, end: range.end }; let priorityFilter = ''; let slaFilter = ''; if (priority && priority !== 'all') { priorityFilter = `AND wf.priority = :priority`; replacements.priority = priority.toUpperCase(); } // SLA filter logic - will be applied in main query if (slaCompliance && slaCompliance !== 'all') { if (slaCompliance === 'breached') { slaFilter = `AND al.tat_breached = true`; } else if (slaCompliance === 'compliant') { slaFilter = `AND (al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours))`; } } // Get total count - only count distinct approvers who have completed approvals // IMPORTANT: WHERE conditions must match the main query to avoid pagination mismatch const countResult = await sequelize.query(` SELECT COUNT(*) as total FROM ( SELECT DISTINCT al.approver_id FROM approval_levels al INNER JOIN workflow_requests wf ON al.request_id = wf.request_id WHERE al.action_date BETWEEN :start AND :end AND al.status IN ('APPROVED', 'REJECTED') AND al.action_date IS NOT NULL AND al.level_start_time IS NOT NULL AND al.tat_hours > 0 AND al.approver_id IS NOT NULL AND al.elapsed_hours IS NOT NULL AND al.elapsed_hours >= 0 ${priorityFilter} ${slaFilter} GROUP BY al.approver_id HAVING COUNT(DISTINCT al.level_id) > 0 ) AS distinct_approvers `, { replacements, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any)?.total || 0); const totalPages = Math.ceil(totalRecords / limit); // Get approver performance metrics (approved/rejected in date range) // IMPORTANT: This must only count approvals where the user acted as APPROVER, not as INITIATOR // TAT % = (Requests approved within TAT / Total requests approved) * 100 // Check if elapsed_hours < tat_hours to determine if within TAT (exact match = within but not ideal) // Exclude records with NULL or 0 elapsed_hours (invalid data) const approverMetrics = await sequelize.query(` SELECT al.approver_id, al.approver_name, COUNT(DISTINCT al.level_id)::int AS total_approved, COUNT(DISTINCT CASE WHEN al.status = 'APPROVED' THEN al.level_id END)::int AS approved_count, COUNT(DISTINCT CASE WHEN al.status = 'REJECTED' THEN al.level_id END)::int AS rejected_count, COUNT(DISTINCT CASE WHEN wf.status = 'CLOSED' THEN al.level_id END)::int AS closed_count, COUNT(DISTINCT CASE WHEN al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours) THEN al.level_id END)::int AS within_tat_count, COUNT(DISTINCT CASE WHEN al.tat_breached = true THEN al.level_id END)::int AS breached_count, ROUND( ((COUNT(DISTINCT CASE WHEN al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours) THEN al.level_id END)::numeric / NULLIF(COUNT(DISTINCT al.level_id), 0)) * 100)::numeric, 0 )::int AS tat_compliance_percent, ROUND(AVG(COALESCE(al.elapsed_hours, 0))::numeric, 1) AS avg_response_hours FROM approval_levels al INNER JOIN workflow_requests wf ON al.request_id = wf.request_id WHERE al.action_date BETWEEN :start AND :end AND al.status IN ('APPROVED', 'REJECTED') AND al.action_date IS NOT NULL AND al.level_start_time IS NOT NULL AND al.tat_hours > 0 AND al.approver_id IS NOT NULL AND al.elapsed_hours IS NOT NULL AND al.elapsed_hours >= 0 ${priorityFilter} ${slaFilter} GROUP BY al.approver_id, al.approver_name HAVING COUNT(DISTINCT al.level_id) > 0 ORDER BY tat_compliance_percent DESC, -- Higher TAT compliance first (100% > 90% > 80%) avg_response_hours ASC, -- Faster response time next (5h < 10h < 20h) total_approved DESC -- More approvals as tie-breaker LIMIT :limit OFFSET :offset `, { replacements: { ...replacements, limit, offset }, type: QueryTypes.SELECT }); // Get current pending counts and calculate TAT compliance including pending requests that have breached const approverIds = approverMetrics.map((a: any) => a.approver_id); let pendingCounts: any[] = []; let pendingBreachData: any[] = []; if (approverIds.length > 0) { // Find all pending/in-progress approval levels and get the first (current) level for each request // This should match the logic from listOpenForMe to ensure consistency pendingCounts = await sequelize.query(` WITH pending_levels AS ( SELECT DISTINCT ON (al.request_id) al.request_id, al.approver_id, al.level_id, al.level_number, al.level_start_time, al.tat_hours, wf.priority, wf.initiator_id FROM approval_levels al JOIN workflow_requests wf ON al.request_id = wf.request_id WHERE al.status IN ('PENDING', 'IN_PROGRESS') AND wf.status IN ('PENDING', 'IN_PROGRESS') AND wf.is_draft = false AND al.level_start_time IS NOT NULL AND al.tat_hours > 0 AND wf.initiator_id != al.approver_id ORDER BY al.request_id, al.level_number ASC ) SELECT approver_id, COUNT(DISTINCT level_id)::int AS pending_count, json_agg(json_build_object( 'level_id', level_id, 'level_start_time', level_start_time, 'tat_hours', tat_hours, 'priority', priority )) AS pending_levels_data FROM pending_levels WHERE approver_id IN (:approverIds) GROUP BY approver_id `, { replacements: { approverIds }, type: QueryTypes.SELECT }); // Calculate SLA status for pending levels to determine breaches const { calculateSLAStatus } = await import('@utils/tatTimeUtils'); pendingBreachData = await Promise.all( pendingCounts.map(async (pc: any) => { const levels = pc.pending_levels_data || []; let breachedCount = 0; let compliantCount = 0; for (const level of levels) { if (level.level_start_time && level.tat_hours > 0) { try { const priority = (level.priority || 'standard').toLowerCase(); const calculated = await calculateSLAStatus( level.level_start_time, level.tat_hours, priority, null // No end date for pending requests ); // Mark as breached if percentageUsed >= 100 if (calculated.percentageUsed >= 100) { breachedCount++; } else { compliantCount++; } } catch (error) { logger.error(`[Dashboard] Error calculating SLA for pending level ${level.level_id}:`, error); // Default to compliant if calculation fails compliantCount++; } } } return { approver_id: pc.approver_id, pending_count: pc.pending_count || 0, pending_breached: breachedCount, pending_compliant: compliantCount }; }) ); } // Create maps for quick lookup const pendingCountMap = new Map(); const pendingBreachedMap = new Map(); const pendingCompliantMap = new Map(); pendingBreachData.forEach((pb: any) => { pendingCountMap.set(pb.approver_id, pb.pending_count || 0); pendingBreachedMap.set(pb.approver_id, pb.pending_breached || 0); pendingCompliantMap.set(pb.approver_id, pb.pending_compliant || 0); }); return { performance: approverMetrics.map((a: any) => { // Get pending breach data const pendingBreached = pendingBreachedMap.get(a.approver_id) || 0; const pendingCompliant = pendingCompliantMap.get(a.approver_id) || 0; // Calculate overall TAT compliance including pending requests // Completed: within_tat_count (compliant) + breached_count (breached) // Pending: pending_compliant (compliant) + pending_breached (breached) const totalCompliant = a.within_tat_count + pendingCompliant; const totalBreached = a.breached_count + pendingBreached; const totalRequests = a.total_approved + pendingBreached + pendingCompliant; // Calculate TAT compliance percentage including pending requests // Use Math.floor to ensure consistent rounding (matches detail screen logic) // This prevents 79.5% from rounding differently in different places const tatCompliancePercent = totalRequests > 0 ? Math.floor((totalCompliant / totalRequests) * 100) : (a.tat_compliance_percent || 0); // Fallback to original if no pending requests return { approverId: a.approver_id, approverName: a.approver_name, totalApproved: a.total_approved, approvedCount: a.approved_count, rejectedCount: a.rejected_count, closedCount: a.closed_count, tatCompliancePercent, avgResponseHours: parseFloat(a.avg_response_hours || 0), pendingCount: pendingCountMap.get(a.approver_id) || 0, withinTatCount: a.within_tat_count, breachedCount: a.breached_count }; }), currentPage: page, totalPages, totalRecords, limit }; } /** * Get recent activity feed with pagination */ async getRecentActivity(userId: string, page: number = 1, limit: number = 10, viewAsUser?: boolean) { // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // For regular users: only activities from their initiated requests OR where they're a participant let whereClause = isAdmin ? '' : ` AND ( wf.initiator_id = :userId OR EXISTS ( SELECT 1 FROM participants p WHERE p.request_id = a.request_id AND p.user_id = :userId ) ) `; // Calculate offset const offset = (page - 1) * limit; // Get total count const countResult = await sequelize.query(` SELECT COUNT(*) as total FROM activities a JOIN workflow_requests wf ON a.request_id = wf.request_id WHERE a.created_at >= NOW() - INTERVAL '7 days' ${whereClause} `, { replacements: { userId }, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any).total); const totalPages = Math.ceil(totalRecords / limit); // Get paginated activities const activities = await sequelize.query(` SELECT a.activity_id, a.request_id, a.activity_type AS type, a.activity_description, a.activity_category, a.user_id, a.user_name, a.created_at AS timestamp, wf.request_number, wf.title AS request_title, wf.priority FROM activities a JOIN workflow_requests wf ON a.request_id = wf.request_id WHERE a.created_at >= NOW() - INTERVAL '7 days' ${whereClause} ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset `, { replacements: { userId, limit, offset }, type: QueryTypes.SELECT }); return { activities: activities.map((a: any) => ({ activityId: a.activity_id, requestId: a.request_id, requestNumber: a.request_number, requestTitle: a.request_title, type: a.type, action: a.activity_description || a.type, details: a.activity_category, userId: a.user_id, userName: a.user_name, timestamp: a.timestamp, priority: (a.priority || '').toLowerCase() })), currentPage: page, totalPages, totalRecords, limit }; } /** * Get critical requests (breached TAT or approaching deadline) with pagination */ async getCriticalRequests(userId: string, page: number = 1, limit: number = 10, viewAsUser?: boolean) { // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // For regular users: show only requests where they are current approver (awaiting their approval) // For admins: show all critical requests organization-wide let whereClause = ` WHERE wf.status IN ('PENDING', 'IN_PROGRESS') AND wf.is_draft = false ${!isAdmin ? `AND EXISTS ( SELECT 1 FROM approval_levels al WHERE al.request_id = wf.request_id AND al.approver_id = :userId AND al.level_number = wf.current_level AND al.status = 'IN_PROGRESS' )` : ''} `; // For TAT Breach Report, only show requests where the CURRENT level has breached // This ensures we don't show requests where a previous level breached but current level is fine const criticalCondition = ` AND EXISTS ( SELECT 1 FROM tat_alerts ta INNER JOIN approval_levels al_current ON ta.level_id = al_current.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true AND al_current.level_number = wf.current_level AND al_current.status = 'IN_PROGRESS' ) `; // Calculate offset const offset = (page - 1) * limit; // Get total count const countResult = await sequelize.query(` SELECT COUNT(*) as total FROM workflow_requests wf ${whereClause} ${criticalCondition} `, { replacements: { userId }, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any).total); const totalPages = Math.ceil(totalRecords / limit); const criticalRequests = await sequelize.query(` SELECT wf.request_id, wf.request_number, wf.title, wf.priority, wf.status, wf.current_level, wf.total_levels, wf.submission_date, wf.total_tat_hours, COALESCE(u.department, 'Unknown') AS department, al.approver_name AS current_approver_name, al.approver_email AS current_approver_email, al.approver_id AS current_approver_id, ( SELECT COUNT(*)::int FROM tat_alerts ta INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true AND al_breach.level_number = wf.current_level ) AS breach_count, ( SELECT ta.alert_sent_at FROM tat_alerts ta INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true AND al_breach.level_number = wf.current_level ORDER BY ta.alert_sent_at DESC LIMIT 1 ) AS first_breach_time, ( SELECT ta.tat_hours_elapsed - ta.tat_hours_allocated FROM tat_alerts ta INNER JOIN approval_levels al_breach ON ta.level_id = al_breach.level_id WHERE ta.request_id = wf.request_id AND ta.is_breached = true AND al_breach.level_number = wf.current_level ORDER BY ta.alert_sent_at DESC LIMIT 1 ) AS breach_hours, ( SELECT al.tat_hours FROM approval_levels al WHERE al.request_id = wf.request_id AND al.level_number = wf.current_level LIMIT 1 ) AS current_level_tat_hours, ( SELECT al.level_start_time FROM approval_levels al WHERE al.request_id = wf.request_id AND al.level_number = wf.current_level LIMIT 1 ) AS current_level_start_time FROM workflow_requests wf LEFT JOIN users u ON wf.initiator_id = u.user_id LEFT JOIN approval_levels al ON al.request_id = wf.request_id AND al.level_number = wf.current_level AND al.status = 'IN_PROGRESS' ${whereClause} ${criticalCondition} ORDER BY CASE WHEN wf.priority = 'EXPRESS' THEN 1 ELSE 2 END, breach_count DESC, wf.created_at ASC LIMIT :limit OFFSET :offset `, { replacements: { userId, limit, offset }, type: QueryTypes.SELECT }); // Calculate working hours TAT for each critical request's current level // Filter out requests where current level hasn't actually breached (TAT < 100%) const criticalWithSLA = await Promise.all(criticalRequests.map(async (req: any) => { const priority = (req.priority || 'standard').toLowerCase(); const currentLevelTatHours = parseFloat(req.current_level_tat_hours) || 0; const currentLevelStartTime = req.current_level_start_time; let currentLevelRemainingHours = currentLevelTatHours; let currentLevelElapsedHours = 0; let tatPercentageUsed = 0; if (currentLevelStartTime && currentLevelTatHours > 0) { try { // Use working hours calculation for current level const slaData = await calculateSLAStatus(currentLevelStartTime, currentLevelTatHours, priority); currentLevelRemainingHours = slaData.remainingHours; currentLevelElapsedHours = slaData.elapsedHours; tatPercentageUsed = slaData.percentageUsed; } catch (error) { logger.error(`[Dashboard] Error calculating SLA for critical request ${req.request_id}:`, error); } } // Trust the is_breached flag from tat_alerts table - if it's marked as breached, include it // The tat_alerts.is_breached flag is set by the TAT monitoring system and should be authoritative // Only filter out if we have a valid TAT calculation AND it's clearly not breached (elapsed < TAT) // BUT if breach_count > 0 from the database, we trust that over the calculation to avoid timing issues // This ensures consistency between Dashboard and All Requests screen const hasBreachFlag = (req.breach_count || 0) > 0; if (currentLevelTatHours > 0 && currentLevelElapsedHours < currentLevelTatHours && !hasBreachFlag) { // Only skip if no breach flag in DB AND calculation shows not breached // If hasBreachFlag is true, trust the database even if calculation hasn't caught up yet return null; // Skip this request - not actually breached } // Calculate breach time (working hours since first breach) let breachTime = 0; if (req.first_breach_time) { // Use working hours calculation instead of calendar hours // This ensures breach time is calculated in working hours, not calendar hours try { const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); breachTime = await calculateElapsedWorkingHours( req.first_breach_time, new Date(), priority ); } catch (error) { logger.error(`[Dashboard] Error calculating working hours for breach time:`, error); // Fallback to calendar hours if working hours calculation fails const breachDate = dayjs(req.first_breach_time); const now = dayjs(); breachTime = now.diff(breachDate, 'hour', true); } } else if (req.breach_hours && req.breach_hours > 0) { // breach_hours is already in working hours from tat_alerts table breachTime = req.breach_hours; } else if (currentLevelElapsedHours > currentLevelTatHours) { // Calculate breach time from elapsed hours (already in working hours) breachTime = currentLevelElapsedHours - currentLevelTatHours; } // Get breach reason from approval_levels table let breachReason = 'TAT Exceeded'; try { const levelWithReason = await sequelize.query(` SELECT al.breach_reason FROM approval_levels al WHERE al.request_id = :requestId AND al.level_number = :currentLevel LIMIT 1 `, { replacements: { requestId: req.request_id, currentLevel: req.current_level }, type: QueryTypes.SELECT }); if (levelWithReason && levelWithReason.length > 0 && (levelWithReason[0] as any).breach_reason) { breachReason = (levelWithReason[0] as any).breach_reason; } else { // Fallback to default reason if (req.breach_count > 0) { if (priority === 'express') { breachReason = 'Express Priority - TAT Exceeded'; } else { breachReason = 'Standard TAT Breach'; } } else if (req.priority === 'EXPRESS') { breachReason = 'Express Priority - High Risk'; } } } catch (error) { logger.warn('[Dashboard] Error fetching breach reason from approval_levels, using default'); // Use default reason on error if (req.breach_count > 0) { if (priority === 'express') { breachReason = 'Express Priority - TAT Exceeded'; } else { breachReason = 'Standard TAT Breach'; } } else if (req.priority === 'EXPRESS') { breachReason = 'Express Priority - High Risk'; } } return { requestId: req.request_id, requestNumber: req.request_number, title: req.title, priority, status: (req.status || '').toLowerCase(), currentLevel: req.current_level, totalLevels: req.total_levels, submissionDate: req.submission_date, totalTATHours: currentLevelRemainingHours, // Current level remaining hours originalTATHours: currentLevelTatHours, // Original TAT hours allocated for current level breachCount: req.breach_count || 0, isCritical: true, // Only true breaches reach here department: req.department || 'Unknown', approver: req.current_approver_name || req.current_approver_email || 'N/A', approverId: req.current_approver_id || null, approverEmail: req.current_approver_email || null, breachTime: breachTime, breachReason: breachReason }; })); // Filter out null values (requests that didn't actually breach) const filteredCritical = criticalWithSLA.filter(req => req !== null); // Since we now trust breach_count from database (if > 0, we include it regardless of calculation), // we should filter very few (if any) requests. The original database count should be accurate. // Only adjust totalRecords if we filtered out requests from current page (for edge cases) // In practice, with the new logic trusting breach_count, filtering should be minimal to none let adjustedTotalRecords = totalRecords; const filteredOutFromPage = criticalRequests.length - filteredCritical.length; if (filteredOutFromPage > 0) { // If we filtered out items from current page, estimate adjustment across all pages // This is an approximation since we can't recalculate without fetching all pages const filterRatio = filteredCritical.length / Math.max(1, criticalRequests.length); adjustedTotalRecords = Math.max(filteredCritical.length, Math.round(totalRecords * filterRatio)); } const adjustedTotalPages = Math.ceil(adjustedTotalRecords / limit); return { criticalRequests: filteredCritical, currentPage: page, totalPages: adjustedTotalPages, totalRecords: adjustedTotalRecords, limit }; } /** * Get upcoming deadlines with pagination */ async getUpcomingDeadlines(userId: string, page: number = 1, limit: number = 10, viewAsUser?: boolean) { // Check if user is admin or management (has broader access) // If viewAsUser is true, treat as normal user even if admin const user = await User.findByPk(userId); const isAdmin = viewAsUser ? false : (user?.hasManagementAccess() || false); // For regular users: only show CURRENT LEVEL where they are the approver // For admins: show all current active levels let whereClause = ` WHERE wf.status IN ('PENDING', 'IN_PROGRESS') AND wf.is_draft = false AND al.status = 'IN_PROGRESS' AND al.level_number = wf.current_level ${!isAdmin ? `AND al.approver_id = :userId` : ''} `; // Calculate offset const offset = (page - 1) * limit; // Get total count const countResult = await sequelize.query(` SELECT COUNT(*) as total FROM approval_levels al JOIN workflow_requests wf ON al.request_id = wf.request_id ${whereClause} `, { replacements: { userId }, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any).total); const totalPages = Math.ceil(totalRecords / limit); const deadlines = await sequelize.query(` SELECT al.level_id, al.request_id, al.level_number, al.approver_name, al.approver_email, al.tat_hours, al.level_start_time, wf.request_number, wf.title AS request_title, wf.priority, wf.current_level, wf.total_levels FROM approval_levels al JOIN workflow_requests wf ON al.request_id = wf.request_id ${whereClause} ORDER BY al.level_start_time ASC LIMIT :limit OFFSET :offset `, { replacements: { userId, limit, offset }, type: QueryTypes.SELECT }); // Calculate working hours TAT for each deadline const deadlinesWithSLA = await Promise.all(deadlines.map(async (d: any) => { const priority = (d.priority || 'standard').toLowerCase(); const tatHours = parseFloat(d.tat_hours) || 0; const levelStartTime = d.level_start_time; let elapsedHours = 0; let remainingHours = tatHours; let tatPercentageUsed = 0; if (levelStartTime && tatHours > 0) { try { // Use working hours calculation (same as RequestDetail screen) const slaData = await calculateSLAStatus(levelStartTime, tatHours, priority); elapsedHours = slaData.elapsedHours; remainingHours = slaData.remainingHours; tatPercentageUsed = slaData.percentageUsed; } catch (error) { logger.error(`[Dashboard] Error calculating SLA for level ${d.level_id}:`, error); } } return { levelId: d.level_id, requestId: d.request_id, requestNumber: d.request_number, requestTitle: d.request_title, levelNumber: d.level_number, currentLevel: d.current_level, totalLevels: d.total_levels, approverName: d.approver_name, approverEmail: d.approver_email, tatHours, elapsedHours, remainingHours, tatPercentageUsed, levelStartTime, priority }; })); // Sort by TAT percentage used (descending) const sortedDeadlines = deadlinesWithSLA.sort((a, b) => b.tatPercentageUsed - a.tatPercentageUsed); return { deadlines: sortedDeadlines, currentPage: page, totalPages, totalRecords, limit }; } /** * Get department-wise statistics */ async getDepartmentStats(userId: string, dateRange?: string, startDate?: string, endDate?: string) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests // Use submission_date instead of created_at to filter by actual submission date let whereClause = ` WHERE wf.submission_date BETWEEN :start AND :end AND wf.is_draft = false AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; const deptStats = await sequelize.query(` SELECT COALESCE(u.department, 'Unknown') AS department, COUNT(*)::int AS total_requests, COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved, COUNT(CASE WHEN wf.status = 'REJECTED' THEN 1 END)::int AS rejected, COUNT(CASE WHEN wf.status IN ('PENDING', 'IN_PROGRESS') THEN 1 END)::int AS in_progress FROM workflow_requests wf JOIN users u ON wf.initiator_id = u.user_id ${whereClause} GROUP BY u.department ORDER BY total_requests DESC LIMIT 10 `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); return deptStats.map((d: any) => ({ department: d.department, totalRequests: d.total_requests, approved: d.approved, rejected: d.rejected, inProgress: d.in_progress, approvalRate: d.total_requests > 0 ? Math.round((d.approved / d.total_requests) * 100) : 0 })); } /** * Get list of unique departments from users (metadata for filtering) * Returns all departments that have at least one user, ordered alphabetically */ async getDepartments(userId: string): Promise { // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // For regular users: only departments from their requests // For admin/management: all departments in the system let whereClause = ''; if (!isAdmin) { // Get departments from requests initiated by this user whereClause = ` WHERE u.department IS NOT NULL AND u.department != '' AND EXISTS ( SELECT 1 FROM workflow_requests wf WHERE wf.initiator_id = u.user_id ) `; } else { // Admin/Management: get all departments that have at least one user whereClause = ` WHERE u.department IS NOT NULL AND u.department != '' `; } const departments = await sequelize.query(` SELECT DISTINCT u.department FROM users u ${whereClause} ORDER BY u.department ASC `, { replacements: !isAdmin ? { userId } : {}, type: QueryTypes.SELECT }); // Extract department names and filter out null/empty values const deptList = (departments as any[]) .map((d: any) => d.department) .filter((dept: string | null) => dept && dept.trim() !== ''); return [...new Set(deptList)]; // Remove duplicates and return } /** * Get priority distribution statistics */ async getPriorityDistribution(userId: string, dateRange?: string, startDate?: string, endDate?: string) { const range = this.parseDateRange(dateRange, startDate, endDate); // Check if user is admin or management (has broader access) const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // For regular users: only their initiated requests // Use submission_date instead of created_at to filter by actual submission date let whereClause = ` WHERE wf.submission_date BETWEEN :start AND :end AND wf.is_draft = false AND wf.submission_date IS NOT NULL ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; // Get all requests for counting (total, approved, breached) const allRequests = await sequelize.query(` SELECT wf.request_id, wf.priority, wf.status, CASE WHEN EXISTS ( SELECT 1 FROM tat_alerts ta WHERE ta.request_id = wf.request_id AND ta.is_breached = true ) THEN 1 ELSE 0 END AS is_breached FROM workflow_requests wf ${whereClause} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); // Get only CLOSED requests for cycle time calculation (ignore APPROVED and REJECTED) let whereClauseCompleted = ` WHERE wf.status = 'CLOSED' AND wf.is_draft = false AND wf.submission_date IS NOT NULL AND ( (wf.closure_date IS NOT NULL AND wf.closure_date BETWEEN :start AND :end) OR (wf.closure_date IS NULL AND wf.updated_at BETWEEN :start AND :end) ) ${!isAdmin ? `AND wf.initiator_id = :userId` : ''} `; const completedRequests = await sequelize.query(` SELECT wf.request_id, wf.priority, wf.submission_date, wf.closure_date, wf.updated_at FROM workflow_requests wf ${whereClauseCompleted} `, { replacements: { start: range.start, end: range.end, userId }, type: QueryTypes.SELECT }); // Group by priority and calculate working hours for each const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); const priorityMap = new Map(); // First, count all requests by priority for (const req of allRequests as any) { const priority = (req.priority || 'STANDARD').toLowerCase(); if (!priorityMap.has(priority)) { priorityMap.set(priority, { totalCount: 0, cycleTimes: [], approvedCount: 0, breachedCount: 0 }); } const stats = priorityMap.get(priority)!; stats.totalCount++; if (req.status === 'APPROVED') { stats.approvedCount++; } if (req.is_breached === 1) { stats.breachedCount++; } } // Then, calculate cycle time only for completed requests for (const req of completedRequests as any) { const priority = (req.priority || 'STANDARD').toLowerCase(); if (!priorityMap.has(priority)) { // This shouldn't happen, but handle it gracefully priorityMap.set(priority, { totalCount: 0, cycleTimes: [], approvedCount: 0, breachedCount: 0 }); } const stats = priorityMap.get(priority)!; // Calculate cycle time using working hours const submissionDate = req.submission_date; const completionDate = req.closure_date || req.updated_at; if (submissionDate && completionDate) { try { const elapsedHours = await calculateElapsedWorkingHours( submissionDate, completionDate, priority ); stats.cycleTimes.push(elapsedHours); } catch (error) { logger.error(`[Dashboard] Error calculating cycle time for request ${req.request_id}:`, error); } } } // Calculate averages per priority (rounded to 2 decimal places for accuracy) return Array.from(priorityMap.entries()).map(([priority, stats]) => { const avgCycleTimeHours = stats.cycleTimes.length > 0 ? Math.round((stats.cycleTimes.reduce((sum, hours) => sum + hours, 0) / stats.cycleTimes.length) * 100) / 100 : 0; return { priority, totalCount: stats.totalCount, avgCycleTimeHours, approvedCount: stats.approvedCount, breachedCount: stats.breachedCount, complianceRate: stats.totalCount > 0 ? Math.round(((stats.totalCount - stats.breachedCount) / stats.totalCount) * 100) : 0 }; }); } /** * Get Request Lifecycle Report with full timeline and TAT compliance */ async getLifecycleReport(userId: string, page: number = 1, limit: number = 50, dateRange?: string, startDate?: string, endDate?: string) { const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; const offset = (page - 1) * limit; // Parse date range if provided let dateFilter = ''; const replacements: any = { userId, limit, offset }; if (dateRange) { const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate); dateFilter = ` AND wf.submission_date IS NOT NULL AND wf.submission_date >= :dateStart AND wf.submission_date <= :dateEnd `; replacements.dateStart = dateFilterObj.start; replacements.dateEnd = dateFilterObj.end; } // For regular users: only their initiated requests or where they're participants let whereClause = isAdmin ? '' : ` AND ( wf.initiator_id = :userId OR EXISTS ( SELECT 1 FROM participants p WHERE p.request_id = wf.request_id AND p.user_id = :userId ) ) `; // Get total count const countResult = await sequelize.query(` SELECT COUNT(*) as total FROM workflow_requests wf WHERE wf.is_draft = false ${dateFilter} ${whereClause} `, { replacements, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any).total); const totalPages = Math.ceil(totalRecords / limit); // Get requests with initiator name and current level name const requests = await sequelize.query(` SELECT wf.request_id, wf.request_number, wf.title, wf.priority, wf.status, wf.submission_date, wf.closure_date, wf.current_level, wf.total_levels, wf.total_tat_hours, wf.created_at, wf.updated_at, u.display_name AS initiator_name, u.email AS initiator_email, al.level_name AS current_stage_name, al.approver_name AS current_approver_name, ( SELECT COUNT(*) FROM tat_alerts ta WHERE ta.request_id = wf.request_id AND ta.is_breached = true ) AS breach_count FROM workflow_requests wf LEFT JOIN users u ON wf.initiator_id = u.user_id LEFT JOIN approval_levels al ON al.request_id = wf.request_id AND al.level_number = wf.current_level WHERE wf.is_draft = false ${dateFilter} ${whereClause} ORDER BY wf.updated_at DESC LIMIT :limit OFFSET :offset `, { replacements, type: QueryTypes.SELECT }); // Calculate overall TAT and compliance for each request const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils'); const lifecycleData = await Promise.all(requests.map(async (req: any) => { const submissionDate = req.submission_date; const endDate = req.closure_date || new Date(); const priority = (req.priority || 'STANDARD').toLowerCase(); // Calculate elapsed working hours const elapsedHours = submissionDate ? await calculateElapsedWorkingHours(submissionDate, endDate, priority) : 0; // Determine TAT compliance const isBreached = req.breach_count > 0; const status = isBreached ? 'Delayed' : 'On Time'; return { requestId: req.request_id, requestNumber: req.request_number, title: req.title, priority: (req.priority || 'STANDARD').toLowerCase(), status, initiatorName: req.initiator_name || req.initiator_email || 'Unknown', initiatorEmail: req.initiator_email, submissionDate: req.submission_date, closureDate: req.closure_date, currentLevel: req.current_level, totalLevels: req.total_levels, currentStageName: req.current_stage_name || `Level ${req.current_level}`, currentApproverName: req.current_approver_name, overallTATHours: elapsedHours, totalTATHours: parseFloat(req.total_tat_hours || 0), breachCount: parseInt(req.breach_count || 0), createdAt: req.created_at, updatedAt: req.updated_at }; })); return { lifecycleData, currentPage: page, totalPages, totalRecords, limit }; } /** * Get enhanced User Activity Log Report with IP and user agent */ async getActivityLogReport( userId: string, page: number = 1, limit: number = 50, dateRange?: string, filterUserId?: string, filterType?: string, filterCategory?: string, filterSeverity?: string, startDate?: string, endDate?: string ) { const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; const range = this.parseDateRange(dateRange, startDate, endDate); const offset = (page - 1) * limit; // For admins: no restrictions - can see ALL activities from ALL users (including login activities) // For regular users: only activities from their initiated requests OR where they're a participant // Also include system events (like login) where the user_id matches let whereClause = isAdmin ? '' : ` AND ( a.user_id = :userId OR wf.initiator_id = :userId OR EXISTS ( SELECT 1 FROM participants p WHERE p.request_id = a.request_id AND p.user_id = :userId ) ) `; // Add filters if (filterUserId) { whereClause += ` AND a.user_id = :filterUserId`; } if (filterType) { whereClause += ` AND a.activity_type = :filterType`; } if (filterCategory) { whereClause += ` AND a.activity_category = :filterCategory`; } if (filterSeverity) { whereClause += ` AND a.severity = :filterSeverity`; } // Get total count const countResult = await sequelize.query(` SELECT COUNT(*) as total FROM activities a LEFT JOIN workflow_requests wf ON a.request_id = wf.request_id WHERE a.created_at BETWEEN :start AND :end ${whereClause} `, { replacements: { userId, start: range.start, end: range.end, filterUserId: filterUserId || null, filterType: filterType || null, filterCategory: filterCategory || null, filterSeverity: filterSeverity || null }, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any).total); const totalPages = Math.ceil(totalRecords / limit); // Get paginated activities with IP and user agent const activities = await sequelize.query(` SELECT a.activity_id, a.request_id, a.activity_type AS type, a.activity_description, a.activity_category, a.user_id, a.user_name, a.created_at AS timestamp, a.ip_address, a.user_agent, wf.request_number, wf.title AS request_title, wf.priority FROM activities a LEFT JOIN workflow_requests wf ON a.request_id = wf.request_id WHERE a.created_at BETWEEN :start AND :end ${whereClause} ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset `, { replacements: { userId, start: range.start, end: range.end, limit, offset, filterUserId: filterUserId || null, filterType: filterType || null, filterCategory: filterCategory || null, filterSeverity: filterSeverity || null }, type: QueryTypes.SELECT }); return { activities: activities.map((a: any) => ({ activityId: a.activity_id, requestId: a.request_id, requestNumber: a.request_number || null, requestTitle: a.request_title || null, type: a.type, action: a.activity_description || a.type, details: a.activity_description || a.activity_category || a.type, // Use activity_description for login details userId: a.user_id, userName: a.user_name, timestamp: a.timestamp, ipAddress: a.ip_address, userAgent: a.user_agent, priority: (a.priority || '').toLowerCase() })), currentPage: page, totalPages, totalRecords, limit }; } /** * Get Workflow Aging Report with business days calculation * Uses optimized server-side pagination with business days calculation */ async getWorkflowAgingReport( userId: string, threshold: number = 7, page: number = 1, limit: number = 50, dateRange?: string, startDate?: string, endDate?: string ) { const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; const range = this.parseDateRange(dateRange, startDate, endDate); // For regular users: only their initiated requests or where they're participants let whereClause = isAdmin ? '' : ` AND ( wf.initiator_id = :userId OR EXISTS ( SELECT 1 FROM participants p WHERE p.request_id = wf.request_id AND p.user_id = :userId ) ) `; // Step 1: Get ALL active requests that might match (for accurate business days calculation) // We need to calculate business days for all to filter correctly, but we'll optimize the calculation const allRequests = await sequelize.query(` SELECT wf.request_id, wf.request_number, wf.title, wf.priority, wf.status, wf.submission_date, wf.current_level, wf.total_levels, u.display_name AS initiator_name, u.email AS initiator_email, al.level_name AS current_stage_name, al.approver_name AS current_approver_name FROM workflow_requests wf LEFT JOIN users u ON wf.initiator_id = u.user_id LEFT JOIN approval_levels al ON al.request_id = wf.request_id AND al.level_number = wf.current_level WHERE wf.is_draft = false AND wf.status NOT IN ('CLOSED', 'APPROVED', 'REJECTED') AND wf.submission_date IS NOT NULL AND wf.submission_date BETWEEN :start AND :end ${whereClause} ORDER BY wf.submission_date ASC `, { replacements: { userId, start: range.start, end: range.end }, type: QueryTypes.SELECT }); // Step 2: Calculate business days for all requests and filter by threshold // This is necessary for accuracy since business days depend on holidays and working hours config const { calculateBusinessDays } = await import('@utils/tatTimeUtils'); const agingData: any[] = []; // Process requests in parallel batches for better performance const BATCH_SIZE = 50; for (let i = 0; i < allRequests.length; i += BATCH_SIZE) { const batch = allRequests.slice(i, i + BATCH_SIZE); const batchResults = await Promise.all( batch.map(async (req: any) => { const priority = ((req as any).priority || 'STANDARD').toLowerCase(); const businessDays = await calculateBusinessDays( (req as any).submission_date, null, // current date priority ); if (businessDays > threshold) { return { requestId: (req as any).request_id, requestNumber: (req as any).request_number, title: (req as any).title, priority: priority, status: ((req as any).status || 'PENDING').toLowerCase(), initiatorName: (req as any).initiator_name || (req as any).initiator_email || 'Unknown', initiatorEmail: (req as any).initiator_email, submissionDate: (req as any).submission_date, daysOpen: businessDays, currentLevel: (req as any).current_level, totalLevels: (req as any).total_levels, currentStageName: (req as any).current_stage_name || `Level ${(req as any).current_level}`, currentApproverName: (req as any).current_approver_name }; } return null; }) ); // Filter out null results and add to agingData agingData.push(...batchResults.filter((r: any) => r !== null)); } // Step 3: Sort by days open (descending) agingData.sort((a, b) => b.daysOpen - a.daysOpen); // Step 4: Apply server-side pagination const totalRecords = agingData.length; const totalPages = Math.ceil(totalRecords / limit); const offset = (page - 1) * limit; const paginatedData = agingData.slice(offset, offset + limit); return { agingData: paginatedData, currentPage: page, totalPages, totalRecords, limit }; } /** * Get single approver stats only (dedicated API for performance) * Only respects date, priority, and SLA filters */ async getSingleApproverStats( userId: string, approverId: string, dateRange?: string, startDate?: string, endDate?: string, priority?: string, slaCompliance?: string ) { const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // Allow users to view their own performance, or admins to view any approver's performance if (!isAdmin && approverId !== userId) { throw new Error('Unauthorized: You can only view your own performance'); } // Parse date range if provided let dateFilter = ''; const replacements: any = { approverId }; if (dateRange) { const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate); dateFilter = ` AND ( (wf.submission_date IS NOT NULL AND wf.submission_date >= :dateStart AND wf.submission_date <= :dateEnd) OR (al.action_date IS NOT NULL AND al.action_date >= :dateStart AND al.action_date <= :dateEnd) ) `; replacements.dateStart = dateFilterObj.start; replacements.dateEnd = dateFilterObj.end; } // Priority filter let priorityFilter = ''; if (priority && priority !== 'all') { priorityFilter = `AND wf.priority = :priorityFilter`; replacements.priorityFilter = priority.toUpperCase(); } // SLA Compliance filter let slaFilter = ''; if (slaCompliance && slaCompliance !== 'all') { if (slaCompliance === 'breached') { slaFilter = `AND al.tat_breached = true`; } else if (slaCompliance === 'compliant') { slaFilter = `AND (al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours < al.tat_hours))`; } } // Calculate aggregated stats using approval_levels directly // IMPORTANT: totalApproved counts DISTINCT requests, not approval levels // This ensures a single request with multiple actions (e.g., dealer proposal + completion) is counted once // TAT Compliance includes: completed + pending breached + levels from closed workflows const statsQuery = ` SELECT COUNT(DISTINCT al.request_id) as totalApproved, SUM(CASE WHEN al.status = 'APPROVED' THEN 1 ELSE 0 END) as approvedCount, SUM(CASE WHEN al.status = 'REJECTED' THEN 1 ELSE 0 END) as rejectedCount, COUNT(DISTINCT CASE WHEN al.status IN ('PENDING', 'IN_PROGRESS') THEN al.request_id END) as pendingCount, COUNT(DISTINCT CASE WHEN (al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED') AND (al.tat_breached = false OR (al.tat_breached IS NULL AND al.elapsed_hours IS NOT NULL AND al.elapsed_hours < al.tat_hours)) THEN al.request_id END) as withinTatCount, COUNT(DISTINCT CASE WHEN ((al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED') AND al.tat_breached = true) OR (al.status IN ('PENDING', 'IN_PROGRESS') AND al.tat_breached = true) THEN al.request_id END) as breachedCount, COUNT(DISTINCT CASE WHEN al.status IN ('PENDING', 'IN_PROGRESS') AND al.tat_breached = true THEN al.request_id END) as pendingBreachedCount, AVG(CASE WHEN (al.status IN ('APPROVED', 'REJECTED') OR wf.status = 'CLOSED') AND al.elapsed_hours IS NOT NULL AND al.elapsed_hours >= 0 THEN al.elapsed_hours ELSE NULL END) as avgResponseHours, COUNT(DISTINCT CASE WHEN wf.status = 'CLOSED' THEN al.request_id END) as closedCount FROM approval_levels al INNER JOIN workflow_requests wf ON al.request_id = wf.request_id WHERE al.approver_id = :approverId AND wf.is_draft = false ${dateFilter} ${priorityFilter} ${slaFilter} `; const [statsResult] = await sequelize.query(statsQuery, { replacements, type: QueryTypes.SELECT }); const stats = statsResult as any; // Database returns lowercase column names // TAT Compliance calculation includes pending breached requests // Total for compliance = completed + pending breached const totalCompleted = (parseInt(stats.approvedcount) || 0) + (parseInt(stats.rejectedcount) || 0); const pendingBreached = parseInt(stats.pendingbreachedcount) || 0; const totalForCompliance = totalCompleted + pendingBreached; const tatCompliancePercent = totalForCompliance > 0 ? Math.round(((parseInt(stats.withintatcount) || 0) / totalForCompliance) * 100) : 0; // Get approver name const approver = await User.findByPk(approverId); const approverStats = { approverId, approverName: approver ? `${approver.firstName} ${approver.lastName}` : 'Unknown', totalApproved: parseInt(stats.totalapproved) || 0, approvedCount: parseInt(stats.approvedcount) || 0, rejectedCount: parseInt(stats.rejectedcount) || 0, closedCount: parseInt(stats.closedcount) || 0, pendingCount: parseInt(stats.pendingcount) || 0, withinTatCount: parseInt(stats.withintatcount) || 0, breachedCount: parseInt(stats.breachedcount) || 0, tatCompliancePercent, avgResponseHours: parseFloat(stats.avgresponsehours) || 0 }; return approverStats; } /** * Get requests filtered by approver ID with detailed filtering support */ async getRequestsByApprover( userId: string, approverId: string, page: number = 1, limit: number = 50, dateRange?: string, startDate?: string, endDate?: string, status?: string, priority?: string, slaCompliance?: string, search?: string ) { const user = await User.findByPk(userId); const isAdmin = user?.hasManagementAccess() || false; // Allow users to view their own performance, or admins to view any approver's performance if (!isAdmin && approverId !== userId) { return { requests: [], currentPage: page, totalPages: 0, totalRecords: 0, limit }; } const offset = (page - 1) * limit; // Parse date range if provided let dateFilter = ''; const replacements: any = { approverId, limit, offset }; if (dateRange) { const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate); // Filter by submission_date OR approval action_date to include requests approved in date range // This ensures we see requests where the approver acted during the date range, even if submitted earlier dateFilter = ` AND ( (wf.submission_date IS NOT NULL AND wf.submission_date >= :dateStart AND wf.submission_date <= :dateEnd) OR (al.action_date IS NOT NULL AND al.action_date >= :dateStart AND al.action_date <= :dateEnd) ) `; replacements.dateStart = dateFilterObj.start; replacements.dateEnd = dateFilterObj.end; } // Status filter - Filter by the approver's action status, not overall workflow status let statusFilter = ''; if (status && status !== 'all') { if (status === 'pending') { // Show requests where this approver is the current approver AND their level is pending statusFilter = `AND al.status IN ('PENDING', 'IN_PROGRESS')`; } else if (status === 'approved') { // Show requests this approver has approved (regardless of overall workflow status) statusFilter = `AND al.status = 'APPROVED'`; } else if (status === 'rejected') { // Show requests this approver has rejected statusFilter = `AND al.status = 'REJECTED'`; } else if (status === 'closed') { // Show requests that are fully closed statusFilter = `AND wf.status = 'CLOSED'`; } else { // For other statuses, filter by workflow status statusFilter = `AND wf.status = :statusFilter`; replacements.statusFilter = status.toUpperCase(); } } // Priority filter let priorityFilter = ''; if (priority && priority !== 'all') { priorityFilter = `AND wf.priority = :priorityFilter`; replacements.priorityFilter = priority.toUpperCase(); } // Search filter let searchFilter = ''; if (search && search.trim()) { searchFilter = ` AND ( wf.request_number ILIKE :searchTerm OR wf.title ILIKE :searchTerm OR u.display_name ILIKE :searchTerm OR u.email ILIKE :searchTerm ) `; replacements.searchTerm = `%${search.trim()}%`; } // SLA Compliance filter - get requests where this approver was involved let slaFilter = ''; if (slaCompliance && slaCompliance !== 'all') { if (slaCompliance === 'breached') { slaFilter = `AND EXISTS ( SELECT 1 FROM tat_alerts ta INNER JOIN approval_levels al ON ta.level_id = al.level_id WHERE ta.request_id = wf.request_id AND al.approver_id = :approverId AND ta.is_breached = true )`; } else if (slaCompliance === 'compliant') { // Compliant: completed requests that are not breached slaFilter = `AND wf.status IN ('APPROVED', 'REJECTED', 'CLOSED') AND NOT EXISTS ( SELECT 1 FROM tat_alerts ta INNER JOIN approval_levels al ON ta.level_id = al.level_id WHERE ta.request_id = wf.request_id AND al.approver_id = :approverId AND ta.is_breached = true )`; } else { // on_track, approaching, critical - these will be calculated client-side // For now, skip this filter as SLA status is calculated dynamically // The client-side filter will handle these cases } } // Get all requests where this approver has been involved (as approver in any approval level) // Include ALL requests where approver is assigned, regardless of approval status (pending, approved, rejected) // For count, we need to use the same date filter logic const countResult = await sequelize.query(` SELECT COUNT(DISTINCT wf.request_id) as total FROM workflow_requests wf INNER JOIN approval_levels al ON wf.request_id = al.request_id WHERE al.approver_id = :approverId AND wf.is_draft = false AND ( al.status IN ('APPROVED', 'REJECTED') OR al.level_number <= wf.current_level ) ${dateFilter} ${statusFilter} ${priorityFilter} ${slaFilter} ${searchFilter} `, { replacements, type: QueryTypes.SELECT }); const totalRecords = Number((countResult[0] as any).total); const totalPages = Math.ceil(totalRecords / limit); // Get requests with approver's level information - use DISTINCT ON for PostgreSQL // Priority: Show approved/rejected levels first, then pending/in-progress // This ensures we see the approver's actual actions, not just pending assignments const requests = await sequelize.query(` SELECT DISTINCT ON (wf.request_id) wf.request_id, wf.request_number, wf.title, wf.priority, wf.status, wf.submission_date, wf.closure_date, wf.current_level, wf.total_levels, wf.total_tat_hours, wf.created_at, wf.updated_at, u.display_name AS initiator_name, u.email AS initiator_email, u.department AS initiator_department, al.level_id, al.level_number, al.status AS approval_status, al.action_date AS approval_action_date, al.level_start_time, al.tat_hours AS level_tat_hours, al.elapsed_hours AS level_elapsed_hours, ( SELECT COUNT(*) FROM tat_alerts ta WHERE ta.request_id = wf.request_id AND ta.level_id = al.level_id AND ta.is_breached = true ) AS is_breached FROM workflow_requests wf INNER JOIN approval_levels al ON wf.request_id = al.request_id LEFT JOIN users u ON wf.initiator_id = u.user_id WHERE al.approver_id = :approverId AND wf.is_draft = false AND ( al.status IN ('APPROVED', 'REJECTED') OR al.level_number <= wf.current_level ) ${dateFilter} ${statusFilter} ${priorityFilter} ${slaFilter} ${searchFilter} ORDER BY wf.request_id, CASE WHEN al.status = 'APPROVED' THEN 1 WHEN al.status = 'REJECTED' THEN 2 WHEN al.status = 'IN_PROGRESS' THEN 3 WHEN al.status = 'PENDING' THEN 4 ELSE 5 END ASC, al.level_number ASC LIMIT :limit OFFSET :offset `, { replacements, type: QueryTypes.SELECT }); // Calculate SLA status for each request/level combination // This ensures we detect breaches for ALL requests (pending, approved, rejected) const { calculateSLAStatus } = await import('@utils/tatTimeUtils'); const processedRequests = await Promise.all( requests.map(async (req: any) => { let slaStatus = 'on_track'; let isBreached = false; // Calculate SLA status for ALL levels (pending, in-progress, approved, rejected) // This ensures we catch breaches even for pending requests if (req.level_tat_hours && req.level_start_time) { try { const priority = (req.priority || 'standard').toLowerCase(); // For completed levels, use action/closure date; for pending, use current time const levelEndDate = req.approval_action_date || req.closure_date || null; const calculated = await calculateSLAStatus( req.level_start_time, req.level_tat_hours, priority, levelEndDate ); slaStatus = calculated.status; // Mark as breached if percentageUsed >= 100 (same logic as Requests screen) // This catches pending requests that have already breached if (calculated.percentageUsed >= 100) { isBreached = true; } else if (req.is_breached && req.is_breached > 0) { // Also check tat_alerts table for historical breaches isBreached = true; } } catch (error) { logger.error(`[Dashboard] Error calculating SLA status for request ${req.request_id}:`, error); // If calculation fails, check tat_alerts table if (req.is_breached && req.is_breached > 0) { isBreached = true; slaStatus = 'breached'; } else { slaStatus = 'on_track'; } } } else if (req.is_breached && req.is_breached > 0) { // Fallback: if no TAT data but tat_alerts shows breach isBreached = true; slaStatus = 'breached'; } return { requestId: req.request_id, requestNumber: req.request_number, title: req.title, priority: (req.priority || 'STANDARD').toLowerCase(), status: (req.status || 'PENDING').toLowerCase(), initiatorName: req.initiator_name || req.initiator_email || 'Unknown', initiatorEmail: req.initiator_email, initiatorDepartment: req.initiator_department, submissionDate: req.submission_date, closureDate: req.closure_date, createdAt: req.created_at, updatedAt: req.updated_at, currentLevel: req.current_level, totalLevels: req.total_levels, levelId: req.level_id, levelNumber: req.level_number, approvalStatus: (req.approval_status || 'PENDING').toLowerCase(), approvalActionDate: req.approval_action_date, slaStatus, levelTatHours: parseFloat(req.level_tat_hours || 0), levelElapsedHours: parseFloat(req.level_elapsed_hours || 0), isBreached: isBreached, // Use calculated breach status (includes pending requests that breached) totalTatHours: parseFloat(req.total_tat_hours || 0) }; }) ); return { requests: processedRequests, currentPage: page, totalPages, totalRecords, limit }; } } export const dashboardService = new DashboardService();