Re_Backend/src/services/dashboard.service.ts

2740 lines
100 KiB
TypeScript

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, 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,
department?: string,
initiator?: string,
approver?: string,
approverType?: 'current' | 'any',
search?: string,
slaCompliance?: string,
viewAsUser?: boolean
) {
// Check if date range should be applied
const applyDateRange = dateRange !== undefined && dateRange !== null;
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();
}
// 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. 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
const dateFilterClause = applyDateRange
? `wf.submission_date BETWEEN :start AND :end AND wf.submission_date IS NOT NULL`
: `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
const pendingDateFilterClause = applyDateRange
? `wf.submission_date BETWEEN :start AND :end AND wf.submission_date IS NOT NULL`
: `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<string, number[]>();
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) * 100
// This shows what percentage of CLOSED requests were completed within TAT
const compliancePercent = totalCompleted > 0 ? Math.round((compliantCount / totalCompleted) * 100) : 0;
// Average cycle time is already calculated above from priority averages
logger.info(`[Dashboard] Compliance calculation: ${totalCompleted} total completed (CLOSED), ${finalBreachedCount} breached, ${compliantCount} compliant`);
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<string, number>();
const pendingBreachedMap = new Map<string, number>();
const pendingCompliantMap = new Map<string, number>();
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<string[]> {
// 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<string, {
totalCount: number;
cycleTimes: number[];
approvedCount: number;
breachedCount: number;
}>();
// 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
// Count ALL approval levels assigned to this approver (like the All Requests pattern)
// TAT Compliance includes: completed + pending breached + levels from closed workflows
const statsQuery = `
SELECT
COUNT(DISTINCT al.level_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,
SUM(CASE WHEN al.status IN ('PENDING', 'IN_PROGRESS') THEN 1 ELSE 0 END) as pendingCount,
SUM(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 1 ELSE 0
END) as withinTatCount,
SUM(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 1 ELSE 0
END) as breachedCount,
SUM(CASE
WHEN al.status IN ('PENDING', 'IN_PROGRESS')
AND al.tat_breached = true
THEN 1 ELSE 0
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,
SUM(CASE WHEN wf.status = 'CLOSED' THEN 1 ELSE 0 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();