Re_Backend/src/services/dashboard.service.ts

964 lines
30 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 {
/**
* Parse date range string to Date objects
*/
private parseDateRange(dateRange?: string): DateRangeFilter {
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
return {
start: now.subtract(30, 'day').toDate(),
end: now.toDate()
};
}
}
/**
* Get all KPIs for dashboard
*/
async getKPIs(userId: string, dateRange?: string) {
const range = this.parseDateRange(dateRange);
// Run all KPI queries in parallel for performance
const [
requestStats,
tatEfficiency,
approverLoad,
engagement,
aiInsights
] = await Promise.all([
this.getRequestStats(userId, dateRange),
this.getTATEfficiency(userId, dateRange),
this.getApproverLoad(userId, dateRange),
this.getEngagementStats(userId, dateRange),
this.getAIInsights(userId, dateRange)
]);
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) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || false;
// For regular users: show only requests they INITIATED (not participated in)
// For admin: show all requests
let whereClause = `
WHERE wf.created_at BETWEEN :start AND :end
AND wf.is_draft = false
${!isAdmin ? `AND wf.initiator_id = :userId` : ''}
`;
const result = await sequelize.query(`
SELECT
COUNT(*)::int AS total_requests,
COUNT(CASE WHEN wf.status = 'PENDING' OR wf.status = 'IN_PROGRESS' THEN 1 END)::int AS open_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
FROM workflow_requests wf
${whereClause}
`, {
replacements: { start: range.start, end: range.end, userId },
type: QueryTypes.SELECT
});
// Get draft count separately
const draftResult = await sequelize.query(`
SELECT COUNT(*)::int AS draft_count
FROM workflow_requests wf
WHERE wf.is_draft = true
${!isAdmin ? `AND wf.initiator_id = :userId` : ''}
`, {
replacements: { userId },
type: QueryTypes.SELECT
});
const stats = result[0] as any;
const drafts = (draftResult[0] as any);
return {
totalRequests: stats.total_requests || 0,
openRequests: stats.open_requests || 0,
approvedRequests: stats.approved_requests || 0,
rejectedRequests: stats.rejected_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) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || false;
// For regular users: only their initiated requests
// For admin: all requests
let whereClause = `
WHERE wf.created_at BETWEEN :start AND :end
AND wf.status IN ('APPROVED', 'REJECTED')
AND wf.is_draft = false
${!isAdmin ? `AND wf.initiator_id = :userId` : ''}
`;
const result = await sequelize.query(`
SELECT
COUNT(*)::int AS total_completed,
COUNT(CASE WHEN EXISTS (
SELECT 1 FROM tat_alerts ta
WHERE ta.request_id = wf.request_id
AND ta.is_breached = true
) THEN 1 END)::int AS breached_count,
AVG(
EXTRACT(EPOCH FROM (wf.updated_at - wf.submission_date)) / 3600
)::numeric AS avg_cycle_time_hours
FROM workflow_requests wf
${whereClause}
`, {
replacements: { start: range.start, end: range.end, userId },
type: QueryTypes.SELECT
});
const stats = result[0] as any;
const totalCompleted = stats.total_completed || 0;
const breachedCount = stats.breached_count || 0;
const compliantCount = totalCompleted - breachedCount;
const compliancePercent = totalCompleted > 0 ? Math.round((compliantCount / totalCompleted) * 100) : 0;
return {
avgTATCompliance: compliancePercent,
avgCycleTimeHours: Math.round(parseFloat(stats.avg_cycle_time_hours || 0) * 10) / 10,
avgCycleTimeDays: Math.round((parseFloat(stats.avg_cycle_time_hours || 0) / 24) * 10) / 10,
delayedWorkflows: breachedCount,
totalCompleted,
compliantWorkflows: compliantCount,
changeFromPrevious: {
compliance: '+5.8%', // TODO: Calculate actual change
cycleTime: '-0.5h'
}
};
}
/**
* Get approver load statistics
*/
async getApproverLoad(userId: string, dateRange?: string) {
const range = this.parseDateRange(dateRange);
// 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
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 in date range
const completedResult = await sequelize.query(`
SELECT
COUNT(*)::int AS completed_today,
COUNT(CASE WHEN al.action_date >= :weekStart 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 BETWEEN :start AND :end
`, {
replacements: {
userId,
start: range.start,
end: range.end,
weekStart: dayjs().startOf('week').toDate()
},
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) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || 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) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || false;
// For regular users: only their initiated requests
let whereClause = `
WHERE wf.created_at BETWEEN :start AND :end
AND wf.status = 'APPROVED'
AND wf.conclusion_remark IS NOT NULL
AND wf.is_draft = false
${!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) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || 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
*/
async getApproverPerformance(userId: string, dateRange?: string, page: number = 1, limit: number = 10) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || 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;
// Get total count
const countResult = await sequelize.query(`
SELECT COUNT(DISTINCT al.approver_id) as total
FROM approval_levels al
WHERE al.action_date BETWEEN :start AND :end
AND al.status IN ('APPROVED', 'REJECTED')
HAVING COUNT(*) > 0
`, {
replacements: { start: range.start, end: range.end },
type: QueryTypes.SELECT
});
const totalRecords = Number((countResult[0] as any)?.total || 0);
const totalPages = Math.ceil(totalRecords / limit);
// Get approver performance metrics
const approverMetrics = await sequelize.query(`
SELECT
al.approver_id,
al.approver_name,
COUNT(*)::int AS total_approved,
ROUND(
AVG(
CASE
WHEN al.tat_breached = false THEN 100
ELSE 0
END
), 0
)::int AS tat_compliance_percent,
ROUND(AVG(al.elapsed_hours)::numeric, 1) AS avg_response_hours,
COUNT(CASE WHEN al.status = 'PENDING' THEN 1 END)::int AS pending_count
FROM approval_levels al
WHERE al.action_date BETWEEN :start AND :end
AND al.status IN ('APPROVED', 'REJECTED')
GROUP BY al.approver_id, al.approver_name
HAVING COUNT(*) > 0
ORDER BY total_approved DESC
LIMIT :limit OFFSET :offset
`, {
replacements: { start: range.start, end: range.end, limit, offset },
type: QueryTypes.SELECT
});
return {
performance: approverMetrics.map((a: any) => ({
approverId: a.approver_id,
approverName: a.approver_name,
totalApproved: a.total_approved,
tatCompliancePercent: a.tat_compliance_percent,
avgResponseHours: parseFloat(a.avg_response_hours || 0),
pendingCount: a.pending_count
})),
currentPage: page,
totalPages,
totalRecords,
limit
};
}
/**
* Get recent activity feed with pagination
*/
async getRecentActivity(userId: string, page: number = 1, limit: number = 10) {
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || 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) {
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || false;
// For regular users: show only their initiated requests OR where they are current approver
let whereClause = `
WHERE wf.status IN ('PENDING', 'IN_PROGRESS')
AND wf.is_draft = false
${!isAdmin ? `AND (
wf.initiator_id = :userId
OR 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'
)
)` : ''}
`;
const criticalCondition = `
AND (
-- Has TAT breaches
EXISTS (
SELECT 1 FROM tat_alerts ta
WHERE ta.request_id = wf.request_id
AND (ta.is_breached = true OR ta.threshold_percentage >= 75)
)
-- Or is express priority
OR wf.priority = 'EXPRESS'
)
`;
// 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,
(
SELECT COUNT(*)::int
FROM tat_alerts ta
WHERE ta.request_id = wf.request_id
AND ta.is_breached = true
) AS breach_count,
(
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
${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
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;
if (currentLevelStartTime && currentLevelTatHours > 0) {
try {
// Use working hours calculation for current level
const slaData = await calculateSLAStatus(currentLevelStartTime, currentLevelTatHours, priority);
currentLevelRemainingHours = slaData.remainingHours;
} catch (error) {
logger.error(`[Dashboard] Error calculating SLA for critical request ${req.request_id}:`, error);
}
}
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: req.breach_count > 0 || req.priority === 'EXPRESS'
};
}));
return {
criticalRequests: criticalWithSLA,
currentPage: page,
totalPages,
totalRecords,
limit
};
}
/**
* Get upcoming deadlines with pagination
*/
async getUpcomingDeadlines(userId: string, page: number = 1, limit: number = 10) {
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || 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) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || false;
// For regular users: only their initiated requests
let whereClause = `
WHERE wf.created_at BETWEEN :start AND :end
AND wf.is_draft = false
${!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 priority distribution statistics
*/
async getPriorityDistribution(userId: string, dateRange?: string) {
const range = this.parseDateRange(dateRange);
// Check if user is admin
const user = await User.findByPk(userId);
const isAdmin = (user as any)?.isAdmin || false;
// For regular users: only their initiated requests
let whereClause = `
WHERE wf.created_at BETWEEN :start AND :end
AND wf.is_draft = false
${!isAdmin ? `AND wf.initiator_id = :userId` : ''}
`;
const priorityStats = await sequelize.query(`
SELECT
wf.priority,
COUNT(*)::int AS total_count,
AVG(
EXTRACT(EPOCH FROM (wf.updated_at - wf.submission_date)) / 3600
)::numeric AS avg_cycle_time_hours,
COUNT(CASE WHEN wf.status = 'APPROVED' THEN 1 END)::int AS approved_count,
COUNT(CASE WHEN EXISTS (
SELECT 1 FROM tat_alerts ta
WHERE ta.request_id = wf.request_id
AND ta.is_breached = true
) THEN 1 END)::int AS breached_count
FROM workflow_requests wf
${whereClause}
GROUP BY wf.priority
`, {
replacements: { start: range.start, end: range.end, userId },
type: QueryTypes.SELECT
});
return priorityStats.map((p: any) => ({
priority: (p.priority || 'STANDARD').toLowerCase(),
totalCount: p.total_count,
avgCycleTimeHours: Math.round(parseFloat(p.avg_cycle_time_hours || 0) * 10) / 10,
approvedCount: p.approved_count,
breachedCount: p.breached_count,
complianceRate: p.total_count > 0 ? Math.round(((p.total_count - p.breached_count) / p.total_count) * 100) : 0
}));
}
}
export const dashboardService = new DashboardService();