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:
@@ -0,0 +1,232 @@
|
||||
import { VacationStatus } from "@planarchy/db";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
|
||||
type DbClient = {
|
||||
vacation: {
|
||||
findUnique: (args: {
|
||||
where: { id: string };
|
||||
select: {
|
||||
id: true;
|
||||
resourceId: true;
|
||||
startDate: true;
|
||||
endDate: true;
|
||||
resource: { select: { chapter: true; displayName: true } };
|
||||
};
|
||||
}) => Promise<{
|
||||
id: string;
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
resource: { chapter: string | null; displayName: string } | null;
|
||||
} | null>;
|
||||
findMany: (args: {
|
||||
where: {
|
||||
resource: { chapter: string };
|
||||
resourceId: { not: string };
|
||||
status: { in: string[] };
|
||||
startDate: { lte: Date };
|
||||
endDate: { gte: Date };
|
||||
};
|
||||
select: {
|
||||
id: true;
|
||||
resourceId: true;
|
||||
startDate: true;
|
||||
endDate: true;
|
||||
resource: { select: { displayName: true } };
|
||||
};
|
||||
}) => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
resource: { displayName: string } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
resource: {
|
||||
count: (args: {
|
||||
where: { chapter: string; isActive: true };
|
||||
}) => Promise<number>;
|
||||
};
|
||||
notification: {
|
||||
create: (args: {
|
||||
data: {
|
||||
userId: string;
|
||||
type: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
title: string;
|
||||
body: string;
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
link: string;
|
||||
channel: string;
|
||||
};
|
||||
}) => Promise<{ id: string; userId: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
/** Threshold: warn when more than 50% of a chapter is absent on any single day */
|
||||
const OVERLAP_THRESHOLD = 0.5;
|
||||
|
||||
export interface VacationConflictResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if approving a vacation would cause >50% of a chapter to be absent
|
||||
* on any single day within the vacation period.
|
||||
*
|
||||
* Returns a list of warning strings (empty if no conflicts).
|
||||
* Does NOT block the approval — warnings are advisory only.
|
||||
*/
|
||||
export async function checkVacationConflicts(
|
||||
db: DbClient,
|
||||
vacationId: string,
|
||||
approverUserId?: string,
|
||||
): Promise<VacationConflictResult> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const vacation = await db.vacation.findUnique({
|
||||
where: { id: vacationId },
|
||||
select: {
|
||||
id: true,
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
resource: { select: { chapter: true, displayName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!vacation?.resource?.chapter) {
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
const chapter = vacation.resource.chapter;
|
||||
|
||||
// Count active resources in the same chapter
|
||||
const totalInChapter = await db.resource.count({
|
||||
where: { chapter, isActive: true },
|
||||
});
|
||||
|
||||
if (totalInChapter <= 1) {
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
// Find overlapping approved/pending vacations from other resources in the same chapter
|
||||
const overlapping = await db.vacation.findMany({
|
||||
where: {
|
||||
resource: { chapter },
|
||||
resourceId: { not: vacation.resourceId },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
startDate: { lte: vacation.endDate },
|
||||
endDate: { gte: vacation.startDate },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
resource: { select: { displayName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (overlapping.length === 0) {
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
// Check each day of the vacation to find the worst overlap
|
||||
const start = new Date(vacation.startDate);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(vacation.endDate);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
let worstDay: string | null = null;
|
||||
let worstCount = 0;
|
||||
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
// Skip weekends
|
||||
const dow = cursor.getUTCDay();
|
||||
if (dow === 0 || dow === 6) {
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count unique resources absent on this day (excluding the current resource)
|
||||
const absentResourceIds = new Set<string>();
|
||||
for (const ov of overlapping) {
|
||||
const ovStart = new Date(ov.startDate);
|
||||
ovStart.setUTCHours(0, 0, 0, 0);
|
||||
const ovEnd = new Date(ov.endDate);
|
||||
ovEnd.setUTCHours(0, 0, 0, 0);
|
||||
if (cursor >= ovStart && cursor <= ovEnd) {
|
||||
absentResourceIds.add(ov.resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
// +1 because the resource being approved would also be absent
|
||||
const totalAbsent = absentResourceIds.size + 1;
|
||||
if (totalAbsent > worstCount) {
|
||||
worstCount = totalAbsent;
|
||||
worstDay = cursor.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
if (worstCount > 0 && worstCount / totalInChapter > OVERLAP_THRESHOLD) {
|
||||
const pct = Math.round((worstCount / totalInChapter) * 100);
|
||||
const absentNames = overlapping
|
||||
.map((ov) => ov.resource?.displayName ?? "Unknown")
|
||||
.slice(0, 5);
|
||||
const nameList = absentNames.join(", ");
|
||||
const suffix = overlapping.length > 5 ? ` and ${overlapping.length - 5} more` : "";
|
||||
|
||||
const warning = `High absence in chapter "${chapter}" on ${worstDay}: ${worstCount}/${totalInChapter} resources (${pct}%) would be absent. Also off: ${nameList}${suffix}`;
|
||||
warnings.push(warning);
|
||||
|
||||
// Create a notification for the approver if provided
|
||||
if (approverUserId) {
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: approverUserId,
|
||||
type: "VACATION_CONFLICT_WARNING",
|
||||
category: "NOTIFICATION",
|
||||
priority: "HIGH",
|
||||
title: `Vacation conflict warning: ${vacation.resource.displayName}`,
|
||||
body: warning,
|
||||
entityId: vacationId,
|
||||
entityType: "vacation",
|
||||
link: "/vacations",
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
emitNotificationCreated(approverUserId, notification.id);
|
||||
}
|
||||
}
|
||||
|
||||
return { warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check conflicts for multiple vacations at once (used by batchApprove).
|
||||
* Returns a map of vacationId -> warnings.
|
||||
*/
|
||||
export async function checkBatchVacationConflicts(
|
||||
db: DbClient,
|
||||
vacationIds: string[],
|
||||
approverUserId?: string,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const results = new Map<string, string[]>();
|
||||
|
||||
for (const id of vacationIds) {
|
||||
const result = await checkVacationConflicts(db, id, approverUserId);
|
||||
if (result.warnings.length > 0) {
|
||||
results.set(id, result.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user