243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
import type { PrismaClient } from "@capakraken/db";
|
|
import { VacationStatus } from "@capakraken/db";
|
|
import { AllocationStatus } from "@capakraken/shared";
|
|
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
|
import { calculateInclusiveDays } from "./shared.js";
|
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
|
import {
|
|
calculateEffectiveAllocationCostCents,
|
|
loadDailyAvailabilityContexts,
|
|
} from "./holiday-capacity.js";
|
|
|
|
function hasAvailability<T extends { availability?: unknown }>(
|
|
resource: T | null | undefined,
|
|
): resource is T & { availability: WeekdayAvailability } {
|
|
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
|
}
|
|
|
|
export async function getDashboardOverview(db: PrismaClient) {
|
|
const [
|
|
totalResources,
|
|
activeResources,
|
|
totalProjects,
|
|
allProjects,
|
|
allDemandRequirements,
|
|
allAssignments,
|
|
budgetAssignments,
|
|
recentActivity,
|
|
allResources,
|
|
approvedVacations,
|
|
totalEstimates,
|
|
] = await Promise.all([
|
|
db.resource.count(),
|
|
db.resource.count({ where: { isActive: true } }),
|
|
db.project.count(),
|
|
db.project.findMany({ select: { status: true, budgetCents: true } }),
|
|
db.demandRequirement.findMany({
|
|
select: {
|
|
id: true,
|
|
|
|
projectId: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
hoursPerDay: true,
|
|
percentage: true,
|
|
role: true,
|
|
roleId: true,
|
|
headcount: true,
|
|
status: true,
|
|
metadata: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
}),
|
|
db.assignment.findMany({
|
|
select: {
|
|
id: true,
|
|
|
|
demandRequirementId: true,
|
|
resourceId: true,
|
|
projectId: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
hoursPerDay: true,
|
|
percentage: true,
|
|
role: true,
|
|
roleId: true,
|
|
dailyCostCents: true,
|
|
status: true,
|
|
metadata: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
}),
|
|
db.assignment.findMany({
|
|
where: { status: { not: "CANCELLED" } },
|
|
select: {
|
|
startDate: true,
|
|
endDate: true,
|
|
dailyCostCents: true,
|
|
resource: {
|
|
select: {
|
|
id: true,
|
|
availability: true,
|
|
countryId: true,
|
|
federalState: true,
|
|
metroCityId: true,
|
|
country: { select: { code: true } },
|
|
metroCity: { select: { name: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
db.auditLog.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
take: 10,
|
|
select: { id: true, entityType: true, action: true, createdAt: true },
|
|
}),
|
|
db.resource.findMany({
|
|
select: { chapter: true, chargeabilityTarget: true },
|
|
}),
|
|
db.vacation.count({ where: { status: VacationStatus.APPROVED } }),
|
|
db.estimate.count(),
|
|
]);
|
|
|
|
const planningReadModel = buildSplitAllocationReadModel({
|
|
demandRequirements: allDemandRequirements,
|
|
assignments: allAssignments,
|
|
});
|
|
const totalAllocations = planningReadModel.allocations.length;
|
|
const activeAllocations = planningReadModel.allocations.filter(
|
|
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
|
|
).length;
|
|
const contextStart = budgetAssignments.length > 0
|
|
? new Date(Math.min(...budgetAssignments.map((assignment) => assignment.startDate.getTime())))
|
|
: new Date();
|
|
const contextEnd = budgetAssignments.length > 0
|
|
? new Date(Math.max(...budgetAssignments.map((assignment) => assignment.endDate.getTime())))
|
|
: new Date();
|
|
const contexts = await loadDailyAvailabilityContexts(
|
|
db,
|
|
budgetAssignments
|
|
.map((assignment) => assignment.resource)
|
|
.filter(hasAvailability)
|
|
.map((resource) => ({
|
|
id: resource.id,
|
|
availability: resource.availability as unknown as WeekdayAvailability,
|
|
countryId: resource.countryId,
|
|
countryCode: resource.country?.code,
|
|
federalState: resource.federalState,
|
|
metroCityId: resource.metroCityId,
|
|
metroCityName: resource.metroCity?.name,
|
|
})),
|
|
contextStart,
|
|
contextEnd,
|
|
);
|
|
|
|
const totalCostCents = budgetAssignments.reduce(
|
|
(sum, assignment) =>
|
|
sum + (
|
|
hasAvailability(assignment.resource)
|
|
? calculateEffectiveAllocationCostCents({
|
|
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
|
startDate: assignment.startDate,
|
|
endDate: assignment.endDate,
|
|
dailyCostCents: assignment.dailyCostCents ?? 0,
|
|
periodStart: assignment.startDate,
|
|
periodEnd: assignment.endDate,
|
|
context: contexts.get(assignment.resource.id),
|
|
})
|
|
: (assignment.dailyCostCents ?? 0) *
|
|
calculateInclusiveDays(assignment.startDate, assignment.endDate)
|
|
),
|
|
0,
|
|
);
|
|
|
|
const totalBudgetCents = allProjects.reduce(
|
|
(sum, project) => sum + (project.budgetCents ?? 0),
|
|
0,
|
|
);
|
|
const activeProjectCount = allProjects.filter((project) => project.status === "ACTIVE").length;
|
|
const inactiveResourceCount = Math.max(totalResources - activeResources, 0);
|
|
const inactiveProjectCount = Math.max(totalProjects - activeProjectCount, 0);
|
|
const cancelledAllocations = Math.max(totalAllocations - activeAllocations, 0);
|
|
const budgetedProjects = allProjects.filter((project) => (project.budgetCents ?? 0) > 0).length;
|
|
const remainingBudgetCents = totalBudgetCents - totalCostCents;
|
|
|
|
const avgUtilizationPercent =
|
|
totalBudgetCents > 0
|
|
? Math.round((totalCostCents / totalBudgetCents) * 100)
|
|
: 0;
|
|
|
|
const statusCountMap = new Map<string, number>();
|
|
for (const project of allProjects) {
|
|
statusCountMap.set(
|
|
project.status,
|
|
(statusCountMap.get(project.status) ?? 0) + 1,
|
|
);
|
|
}
|
|
|
|
const chapterMap = new Map<
|
|
string,
|
|
{ resourceCount: number; chargeabilitySum: number }
|
|
>();
|
|
for (const resource of allResources) {
|
|
const chapter = resource.chapter ?? "Unassigned";
|
|
const existing = chapterMap.get(chapter) ?? {
|
|
resourceCount: 0,
|
|
chargeabilitySum: 0,
|
|
};
|
|
|
|
chapterMap.set(chapter, {
|
|
resourceCount: existing.resourceCount + 1,
|
|
chargeabilitySum:
|
|
existing.chargeabilitySum + (resource.chargeabilityTarget ?? 0),
|
|
});
|
|
}
|
|
|
|
return {
|
|
totalResources,
|
|
activeResources,
|
|
inactiveResources: inactiveResourceCount,
|
|
totalProjects,
|
|
activeProjects: activeProjectCount,
|
|
inactiveProjects: inactiveProjectCount,
|
|
totalAllocations,
|
|
activeAllocations,
|
|
cancelledAllocations,
|
|
approvedVacations,
|
|
totalEstimates,
|
|
budgetSummary: {
|
|
totalBudgetCents,
|
|
totalCostCents,
|
|
avgUtilizationPercent,
|
|
},
|
|
budgetBasis: {
|
|
remainingBudgetCents,
|
|
budgetedProjects,
|
|
unbudgetedProjects: Math.max(totalProjects - budgetedProjects, 0),
|
|
trackedAssignmentCount: budgetAssignments.length,
|
|
windowStart: budgetAssignments.length > 0 ? contextStart : null,
|
|
windowEnd: budgetAssignments.length > 0 ? contextEnd : null,
|
|
},
|
|
recentActivity: recentActivity.map((activity) => ({
|
|
id: activity.id,
|
|
entityType: activity.entityType,
|
|
action: activity.action,
|
|
createdAt: activity.createdAt,
|
|
})),
|
|
projectsByStatus: [...statusCountMap.entries()].map(([status, count]) => ({
|
|
status,
|
|
count,
|
|
})),
|
|
chapterUtilization: [...chapterMap.entries()].map(([chapter, data]) => ({
|
|
chapter,
|
|
resourceCount: data.resourceCount,
|
|
avgChargeabilityTarget:
|
|
data.resourceCount > 0
|
|
? Math.round(data.chargeabilitySum / data.resourceCount)
|
|
: 0,
|
|
})),
|
|
};
|
|
}
|