2768 lines
101 KiB
TypeScript
2768 lines
101 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, 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<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 + 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<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
|
|
// 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();
|
|
|