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:
2026-03-19 21:39:05 +01:00
parent 6e5b9ec85b
commit 6f34659587
16 changed files with 1906 additions and 4 deletions
+224
View File
@@ -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,
};
}),
});