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 { // 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(); for (const d of openDemands) { // Try to extract required skills from metadata const meta = d.metadata as Record | 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(); 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 { 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(); 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(); const supplyByRole = new Map(); for (const resource of resources) { const rawSkills = Array.isArray(resource.skills) ? resource.skills as Array> : []; 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 })), }; }