198 lines
5.5 KiB
TypeScript
198 lines
5.5 KiB
TypeScript
import type { PrismaClient } from "@capakraken/db";
|
|
|
|
export interface SkillGapRow {
|
|
skill: string;
|
|
demand: number;
|
|
supply: number;
|
|
gap: number;
|
|
}
|
|
|
|
interface SkillEntry {
|
|
name: string;
|
|
level?: number;
|
|
}
|
|
|
|
export interface SkillGapSummaryRoleGap {
|
|
role: string;
|
|
needed: number;
|
|
filled: number;
|
|
gap: number;
|
|
fillRate: number;
|
|
}
|
|
|
|
export interface SkillSupplySummaryRow {
|
|
skill: string;
|
|
resourceCount: number;
|
|
}
|
|
|
|
export interface ResourcesByRoleSummaryRow {
|
|
role: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface DashboardSkillGapSummary {
|
|
roleGaps: SkillGapSummaryRoleGap[];
|
|
totalOpenPositions: number;
|
|
skillSupplyTop10: SkillSupplySummaryRow[];
|
|
resourcesByRole: ResourcesByRoleSummaryRow[];
|
|
}
|
|
|
|
export async function getDashboardSkillGaps(
|
|
db: PrismaClient,
|
|
): Promise<SkillGapRow[]> {
|
|
// Count open demand requirements grouped by required skill (from role name)
|
|
const openDemands = await db.demandRequirement.findMany({
|
|
where: {
|
|
status: { in: ["PROPOSED", "CONFIRMED"] },
|
|
project: { status: "ACTIVE" },
|
|
},
|
|
select: {
|
|
role: true,
|
|
roleId: true,
|
|
roleEntity: { select: { name: true } },
|
|
headcount: true,
|
|
metadata: true,
|
|
},
|
|
});
|
|
|
|
// Build demand map by skill/role name
|
|
const demandMap = new Map<string, number>();
|
|
|
|
for (const d of openDemands) {
|
|
// Try to extract required skills from metadata
|
|
const meta = d.metadata as Record<string, unknown> | null;
|
|
const requiredSkills = Array.isArray(meta?.requiredSkills)
|
|
? (meta.requiredSkills as string[])
|
|
: [];
|
|
|
|
if (requiredSkills.length > 0) {
|
|
for (const skill of requiredSkills) {
|
|
const normalized = skill.trim();
|
|
if (normalized) {
|
|
demandMap.set(normalized, (demandMap.get(normalized) ?? 0) + d.headcount);
|
|
}
|
|
}
|
|
} else {
|
|
// Fall back to role name as the "skill"
|
|
const roleName = d.roleEntity?.name ?? d.role;
|
|
if (roleName) {
|
|
demandMap.set(roleName, (demandMap.get(roleName) ?? 0) + d.headcount);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (demandMap.size === 0) return [];
|
|
|
|
// Count active resources with each skill at proficiency >= 3
|
|
const resources = await db.resource.findMany({
|
|
where: { isActive: true },
|
|
select: { skills: true },
|
|
});
|
|
|
|
const supplyMap = new Map<string, number>();
|
|
for (const r of resources) {
|
|
const skills = (r.skills ?? []) as unknown as SkillEntry[];
|
|
if (!Array.isArray(skills)) continue;
|
|
for (const skill of skills) {
|
|
if (!skill.name) continue;
|
|
if ((skill.level ?? 0) >= 3) {
|
|
supplyMap.set(skill.name, (supplyMap.get(skill.name) ?? 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build gap rows for demanded skills
|
|
const rows: SkillGapRow[] = [];
|
|
for (const [skill, demand] of demandMap) {
|
|
const supply = supplyMap.get(skill) ?? 0;
|
|
rows.push({ skill, demand, supply, gap: supply - demand });
|
|
}
|
|
|
|
// Sort by largest shortage first (most negative gap), take top 10
|
|
rows.sort((a, b) => a.gap - b.gap);
|
|
return rows.slice(0, 10);
|
|
}
|
|
|
|
export async function getDashboardSkillGapSummary(
|
|
db: PrismaClient,
|
|
): Promise<DashboardSkillGapSummary> {
|
|
const now = new Date();
|
|
|
|
const demands = await db.demandRequirement.findMany({
|
|
where: {
|
|
project: { status: { in: ["ACTIVE", "DRAFT"] } },
|
|
status: { not: "CANCELLED" },
|
|
endDate: { gte: now },
|
|
},
|
|
select: {
|
|
role: true,
|
|
headcount: true,
|
|
roleEntity: { select: { name: true } },
|
|
_count: { select: { assignments: true } },
|
|
},
|
|
});
|
|
|
|
const demandByRole = new Map<string, { needed: number; filled: number }>();
|
|
for (const demand of demands) {
|
|
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unknown";
|
|
const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 };
|
|
existing.needed += demand.headcount;
|
|
existing.filled += Math.min(demand._count.assignments, demand.headcount);
|
|
demandByRole.set(roleName, existing);
|
|
}
|
|
|
|
const resources = await db.resource.findMany({
|
|
where: { isActive: true },
|
|
select: {
|
|
skills: true,
|
|
areaRole: { select: { name: true } },
|
|
},
|
|
});
|
|
|
|
const skillSupply = new Map<string, number>();
|
|
const supplyByRole = new Map<string, number>();
|
|
|
|
for (const resource of resources) {
|
|
const rawSkills = Array.isArray(resource.skills)
|
|
? resource.skills as Array<Record<string, unknown>>
|
|
: [];
|
|
for (const entry of rawSkills) {
|
|
const skillName = typeof entry.skill === "string"
|
|
? entry.skill
|
|
: typeof entry.name === "string"
|
|
? entry.name
|
|
: null;
|
|
if (!skillName) continue;
|
|
skillSupply.set(skillName.toLowerCase(), (skillSupply.get(skillName.toLowerCase()) ?? 0) + 1);
|
|
}
|
|
|
|
const roleName = resource.areaRole?.name;
|
|
if (roleName) {
|
|
supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
const roleGaps = [...demandByRole.entries()]
|
|
.map(([role, { needed, filled }]) => ({
|
|
role,
|
|
needed,
|
|
filled,
|
|
gap: needed - filled,
|
|
fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100,
|
|
}))
|
|
.filter((gap) => gap.gap > 0)
|
|
.sort((left, right) => right.gap - left.gap);
|
|
|
|
return {
|
|
roleGaps,
|
|
totalOpenPositions: roleGaps.reduce((sum, gap) => sum + gap.gap, 0),
|
|
skillSupplyTop10: [...skillSupply.entries()]
|
|
.sort((left, right) => right[1] - left[1])
|
|
.slice(0, 10)
|
|
.map(([skill, resourceCount]) => ({ skill, resourceCount })),
|
|
resourcesByRole: [...supplyByRole.entries()]
|
|
.sort((left, right) => right[1] - left[1])
|
|
.map(([role, count]) => ({ role, count })),
|
|
};
|
|
}
|