336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import { WorkflowRequest } from '@models/WorkflowRequest';
|
|
import { DealerClaimDetails } from '@models/DealerClaimDetails';
|
|
import { ClaimCreditNote } from '@models/ClaimCreditNote';
|
|
import { DealerProposalDetails } from '@models/DealerProposalDetails';
|
|
import { ClaimBudgetTracking } from '@models/ClaimBudgetTracking';
|
|
import { Op, QueryTypes } from 'sequelize';
|
|
import { sequelize } from '@config/database';
|
|
import dayjs from 'dayjs';
|
|
import logger from '@utils/logger';
|
|
import { User } from '@models/User';
|
|
|
|
interface DateRangeFilter {
|
|
start: Date;
|
|
end: Date;
|
|
}
|
|
|
|
interface DashboardKPIs {
|
|
totalClaims: number;
|
|
totalValue: number;
|
|
approved: number;
|
|
rejected: number;
|
|
pending: number;
|
|
credited: number;
|
|
pendingCredit: number;
|
|
approvedValue: number;
|
|
rejectedValue: number;
|
|
pendingValue: number;
|
|
creditedValue: number;
|
|
pendingCreditValue: number;
|
|
}
|
|
|
|
interface CategoryData {
|
|
activityType: string;
|
|
raised: number;
|
|
raisedValue: number;
|
|
approved: number;
|
|
approvedValue: number;
|
|
rejected: number;
|
|
rejectedValue: number;
|
|
pending: number;
|
|
pendingValue: number;
|
|
credited: number;
|
|
creditedValue: number;
|
|
pendingCredit: number;
|
|
pendingCreditValue: number;
|
|
approvalRate: number;
|
|
creditRate: number;
|
|
}
|
|
|
|
export class DealerDashboardService {
|
|
/**
|
|
* Parse date range string to Date objects
|
|
*/
|
|
private parseDateRange(dateRange?: string, startDate?: string, endDate?: string): DateRangeFilter {
|
|
if (dateRange === 'custom' && startDate && endDate) {
|
|
const start = dayjs(startDate).startOf('day').toDate();
|
|
const end = dayjs(endDate).endOf('day').toDate();
|
|
const now = dayjs();
|
|
const actualEnd = end > now.toDate() ? now.endOf('day').toDate() : end;
|
|
return { start, end: actualEnd };
|
|
}
|
|
|
|
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':
|
|
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:
|
|
return {
|
|
start: now.subtract(30, 'day').startOf('day').toDate(),
|
|
end: now.endOf('day').toDate()
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get dealer email from user email or user ID
|
|
*/
|
|
private async getDealerEmail(userEmail?: string, userId?: string): Promise<string | null> {
|
|
try {
|
|
if (userEmail) {
|
|
// Check if user email matches a dealer email in dealer_claim_details
|
|
const dealerClaim = await DealerClaimDetails.findOne({
|
|
where: {
|
|
dealerEmail: { [Op.iLike]: userEmail.toLowerCase() }
|
|
},
|
|
limit: 1
|
|
});
|
|
if (dealerClaim) {
|
|
return dealerClaim.dealerEmail?.toLowerCase() || null;
|
|
}
|
|
}
|
|
|
|
if (userId) {
|
|
// Get user email from userId
|
|
const user = await User.findByPk(userId);
|
|
if (user?.email) {
|
|
const dealerClaim = await DealerClaimDetails.findOne({
|
|
where: {
|
|
dealerEmail: { [Op.iLike]: user.email.toLowerCase() }
|
|
},
|
|
limit: 1
|
|
});
|
|
if (dealerClaim) {
|
|
return dealerClaim.dealerEmail?.toLowerCase() || null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('[DealerDashboard] Error getting dealer email:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get dashboard KPIs for dealer
|
|
*/
|
|
async getDashboardKPIs(
|
|
userEmail?: string,
|
|
userId?: string,
|
|
dateRange?: string,
|
|
startDate?: string,
|
|
endDate?: string
|
|
): Promise<{ kpis: DashboardKPIs; categoryData: CategoryData[] }> {
|
|
try {
|
|
const dealerEmail = await this.getDealerEmail(userEmail, userId);
|
|
if (!dealerEmail) {
|
|
logger.warn('[DealerDashboard] No dealer email found for user');
|
|
return {
|
|
kpis: {
|
|
totalClaims: 0,
|
|
totalValue: 0,
|
|
approved: 0,
|
|
rejected: 0,
|
|
pending: 0,
|
|
credited: 0,
|
|
pendingCredit: 0,
|
|
approvedValue: 0,
|
|
rejectedValue: 0,
|
|
pendingValue: 0,
|
|
creditedValue: 0,
|
|
pendingCreditValue: 0,
|
|
},
|
|
categoryData: []
|
|
};
|
|
}
|
|
|
|
const applyDateRange = dateRange !== undefined && dateRange !== null && dateRange !== 'all';
|
|
const range = applyDateRange ? this.parseDateRange(dateRange, startDate, endDate) : null;
|
|
|
|
// Build date filter
|
|
const dateFilter = applyDateRange && range
|
|
? `AND (
|
|
(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`;
|
|
|
|
const replacements: any = { dealerEmail: dealerEmail.toLowerCase() };
|
|
if (applyDateRange && range) {
|
|
replacements.start = range.start;
|
|
replacements.end = range.end;
|
|
}
|
|
|
|
// Get all dealer claims with their details
|
|
// Filter by both workflow_type and template_type for compatibility
|
|
const claimsQuery = `
|
|
SELECT
|
|
wf.request_id,
|
|
wf.status,
|
|
dcd.activity_type,
|
|
COALESCE(dpd.total_estimated_budget, cbt.proposal_estimated_budget, 0)::numeric AS estimated_budget,
|
|
COALESCE(cbt.approved_budget, cbt.proposal_estimated_budget, dpd.total_estimated_budget, 0)::numeric AS approved_budget,
|
|
cbt.final_claim_amount::numeric AS final_claim_amount,
|
|
ccn.credit_note_number,
|
|
ccn.credit_note_date,
|
|
ccn.credit_amount::numeric AS credit_note_amount
|
|
FROM workflow_requests wf
|
|
INNER JOIN dealer_claim_details dcd ON wf.request_id = dcd.request_id
|
|
LEFT JOIN dealer_proposal_details dpd ON wf.request_id = dpd.request_id
|
|
LEFT JOIN claim_budget_tracking cbt ON wf.request_id = cbt.request_id
|
|
LEFT JOIN claim_credit_notes ccn ON wf.request_id = ccn.request_id
|
|
WHERE (wf.workflow_type = 'CLAIM_MANAGEMENT' OR wf.template_type = 'DEALER CLAIM')
|
|
AND wf.is_draft = false
|
|
AND (wf.is_deleted IS NULL OR wf.is_deleted = false)
|
|
AND dcd.dealer_email ILIKE :dealerEmail
|
|
AND ${dateFilter}
|
|
`;
|
|
|
|
const claims = await sequelize.query(claimsQuery, {
|
|
replacements,
|
|
type: QueryTypes.SELECT
|
|
}) as any[];
|
|
|
|
// Calculate KPIs
|
|
const kpis: DashboardKPIs = {
|
|
totalClaims: claims.length,
|
|
totalValue: 0,
|
|
approved: 0,
|
|
rejected: 0,
|
|
pending: 0,
|
|
credited: 0,
|
|
pendingCredit: 0,
|
|
approvedValue: 0,
|
|
rejectedValue: 0,
|
|
pendingValue: 0,
|
|
creditedValue: 0,
|
|
pendingCreditValue: 0,
|
|
};
|
|
|
|
// Group by category
|
|
const categoryMap = new Map<string, CategoryData>();
|
|
|
|
for (const claim of claims) {
|
|
const activityType = claim.activity_type || 'Unknown';
|
|
const status = (claim.status || '').toUpperCase();
|
|
const estimatedBudget = parseFloat(claim.estimated_budget || 0);
|
|
const approvedBudget = parseFloat(claim.approved_budget || estimatedBudget);
|
|
const finalClaimAmount = parseFloat(claim.final_claim_amount || approvedBudget);
|
|
const hasCreditNote = !!(claim.credit_note_number && claim.credit_note_date);
|
|
const creditNoteAmount = parseFloat(claim.credit_note_amount || finalClaimAmount);
|
|
|
|
// Initialize category if not exists
|
|
if (!categoryMap.has(activityType)) {
|
|
categoryMap.set(activityType, {
|
|
activityType,
|
|
raised: 0,
|
|
raisedValue: 0,
|
|
approved: 0,
|
|
approvedValue: 0,
|
|
rejected: 0,
|
|
rejectedValue: 0,
|
|
pending: 0,
|
|
pendingValue: 0,
|
|
credited: 0,
|
|
creditedValue: 0,
|
|
pendingCredit: 0,
|
|
pendingCreditValue: 0,
|
|
approvalRate: 0,
|
|
creditRate: 0,
|
|
});
|
|
}
|
|
|
|
const category = categoryMap.get(activityType)!;
|
|
|
|
// Count and values by status
|
|
category.raised++;
|
|
category.raisedValue += estimatedBudget;
|
|
kpis.totalValue += estimatedBudget;
|
|
|
|
if (status === 'APPROVED' || status === 'CLOSED') {
|
|
category.approved++;
|
|
category.approvedValue += approvedBudget;
|
|
kpis.approved++;
|
|
kpis.approvedValue += approvedBudget;
|
|
|
|
if (hasCreditNote) {
|
|
category.credited++;
|
|
category.creditedValue += creditNoteAmount;
|
|
kpis.credited++;
|
|
kpis.creditedValue += creditNoteAmount;
|
|
} else {
|
|
category.pendingCredit++;
|
|
category.pendingCreditValue += finalClaimAmount;
|
|
kpis.pendingCredit++;
|
|
kpis.pendingCreditValue += finalClaimAmount;
|
|
}
|
|
} else if (status === 'REJECTED') {
|
|
category.rejected++;
|
|
category.rejectedValue += estimatedBudget;
|
|
kpis.rejected++;
|
|
kpis.rejectedValue += estimatedBudget;
|
|
} else if (status === 'PENDING' || status === 'IN_PROGRESS') {
|
|
category.pending++;
|
|
category.pendingValue += estimatedBudget;
|
|
kpis.pending++;
|
|
kpis.pendingValue += estimatedBudget;
|
|
}
|
|
}
|
|
|
|
// Calculate rates for each category
|
|
const categoryData = Array.from(categoryMap.values()).map(cat => {
|
|
cat.approvalRate = cat.raised > 0 ? (cat.approved / cat.raised) * 100 : 0;
|
|
cat.creditRate = cat.approved > 0 ? (cat.credited / cat.approved) * 100 : 0;
|
|
return cat;
|
|
});
|
|
|
|
return {
|
|
kpis,
|
|
categoryData
|
|
};
|
|
} catch (error) {
|
|
logger.error('[DealerDashboard] Error fetching dashboard KPIs:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const dealerDashboardService = new DealerDashboardService();
|
|
|