964 lines
30 KiB
TypeScript
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();
|
|
|