feat: Sprint 3 — automation, intelligence, skill marketplace
Auto-Staffing Suggestions (A6): - generateAutoSuggestions() ranks top-3 resources on demand creation - Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%) - Creates in-app notification with match scores for managers - Triggered after createDemandRequirement and partial fillDemandRequirement Vacation Conflict Detection (A7): - checkVacationConflicts() warns when >50% chapter absent on same days - Returns warnings array in approve/batchApprove responses (advisory, non-blocking) - Creates VACATION_CONFLICT_WARNING notification for approver Weekly Chargeability Alerts (A10): - checkChargeabilityAlerts() finds resources >15pp below target - Cron endpoint: GET /api/cron/chargeability-alerts - Duplicate-safe by resourceId + month composite key Rate Card Auto-Apply (A11): - lookupRate() finds best matching rate card line (weighted scoring) - Auto-fills demand line rates in estimate create/updateDraft when rates are 0 - Marks auto-filled lines with metadata.autoAppliedRateCard - New lookupDemandLineRate query for on-demand UI lookups Public Holiday Auto-Import (A12): - autoImportPublicHolidays() generates holidays by resource federal state - Cron endpoint: GET /api/cron/public-holidays?year=2027 - Duplicate-safe, uses existing getPublicHolidays() from shared Skill Marketplace MVP (G6): - New page: /analytics/skill-marketplace with 3 sections - Skill Search: filter by name, proficiency, availability, sortable results - Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators - Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart) - New getSkillMarketplace query in resource router - Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -1191,4 +1191,228 @@ export const resourceRouter = createTRPCRouter({
|
||||
|
||||
return { updated: input.ids.length };
|
||||
}),
|
||||
|
||||
// ─── Skill Marketplace ────────────────────────────────────────────────────
|
||||
|
||||
getSkillMarketplace: controllerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
// Section 1: Skill search
|
||||
searchSkill: z.string().optional(),
|
||||
minProficiency: z.number().int().min(1).max(5).optional().default(1),
|
||||
availableOnly: z.boolean().optional().default(false),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
// ── Fetch all active resources with skills ──
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
chapter: true,
|
||||
skills: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Fetch current assignments for utilization calc ──
|
||||
const allResourceIds = resources.map((r) => r.id);
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: {
|
||||
resourceId: { in: allResourceIds },
|
||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||
endDate: { gte: now },
|
||||
startDate: { lte: thirtyDaysFromNow },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
hoursPerDay: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build utilization map (simple: booked hours per day / available hours per day)
|
||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
|
||||
|
||||
// Current daily booked hours (assignments overlapping today)
|
||||
let todayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
todayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
|
||||
|
||||
// Find earliest date when resource has capacity (within 30 days)
|
||||
let earliestAvailableDate: Date | null = null;
|
||||
const checkDate = new Date(now);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const day = checkDate.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
let dayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= checkDate && a.endDate >= checkDate) {
|
||||
dayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
if (dayBooked < dailyAvailHours * 0.8) {
|
||||
earliestAvailableDate = new Date(checkDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
}
|
||||
|
||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||
}
|
||||
|
||||
// ── Section 1: Skill Search ──
|
||||
let searchResults: Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
skillProficiency: number;
|
||||
skillName: string;
|
||||
utilizationPercent: number;
|
||||
availableFrom: string | null;
|
||||
}> = [];
|
||||
|
||||
if (input.searchSkill && input.searchSkill.trim().length > 0) {
|
||||
const needle = input.searchSkill.toLowerCase();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
const match = skills.find(
|
||||
(s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency,
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const util = utilizationMap.get(r.id);
|
||||
if (input.availableOnly && !util?.earliestAvailableDate) continue;
|
||||
|
||||
searchResults.push({
|
||||
id: r.id,
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
skillProficiency: match.proficiency,
|
||||
skillName: match.skill,
|
||||
utilizationPercent: util?.utilizationPercent ?? 0,
|
||||
availableFrom: util?.earliestAvailableDate?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent);
|
||||
}
|
||||
|
||||
// ── Section 2: Skill Gap Heat Map ──
|
||||
// Demand: from unfilled DemandRequirements + project staffingReqs skills
|
||||
const unfilled = await ctx.db.demandRequirement.findMany({
|
||||
where: {
|
||||
endDate: { gte: now },
|
||||
assignments: { none: {} },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
roleId: true,
|
||||
headcount: true,
|
||||
project: {
|
||||
select: { staffingReqs: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Collect demanded skills from project staffingReqs
|
||||
const demandSkillCounts = new Map<string, number>();
|
||||
for (const demand of unfilled) {
|
||||
const staffingReqs = (demand.project.staffingReqs as unknown as Array<{
|
||||
role?: string;
|
||||
roleId?: string;
|
||||
requiredSkills?: string[];
|
||||
}>) ?? [];
|
||||
|
||||
// Match demand to staffing req by role or roleId
|
||||
const matchedReq = staffingReqs.find(
|
||||
(sr) =>
|
||||
(demand.roleId && sr.roleId === demand.roleId) ||
|
||||
(demand.role && sr.role === demand.role),
|
||||
);
|
||||
|
||||
if (matchedReq?.requiredSkills) {
|
||||
for (const skill of matchedReq.requiredSkills) {
|
||||
demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supply: count resources with skill at proficiency >= 3
|
||||
const supplySkillCounts = new Map<string, number>();
|
||||
const allSkillCounts = new Map<string, number>();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1);
|
||||
if (s.proficiency >= 3) {
|
||||
supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all skill names from both demand and supply
|
||||
const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]);
|
||||
const gapData = Array.from(allGapSkills)
|
||||
.map((skill) => {
|
||||
const supply = supplySkillCounts.get(skill) ?? 0;
|
||||
const demand = demandSkillCounts.get(skill) ?? 0;
|
||||
return { skill, supply, demand, gap: demand - supply };
|
||||
})
|
||||
.sort((a, b) => b.gap - a.gap);
|
||||
|
||||
// ── Section 3: Distribution (top 20 by resource count) ──
|
||||
const aggregated = Array.from(
|
||||
(() => {
|
||||
const map = new Map<string, { skill: string; count: number; totalProficiency: number }>();
|
||||
for (const r of resources) {
|
||||
const skills = (r.skills as unknown as SkillRow[]) ?? [];
|
||||
for (const s of skills) {
|
||||
const entry = map.get(s.skill);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
entry.totalProficiency += s.proficiency;
|
||||
} else {
|
||||
map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency });
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})().values(),
|
||||
)
|
||||
.map((e) => ({
|
||||
skill: e.skill,
|
||||
count: e.count,
|
||||
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20);
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return {
|
||||
searchResults: anonymizeResources(searchResults, directory),
|
||||
gapData,
|
||||
distribution: aggregated,
|
||||
totalResources: resources.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user