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,121 @@
|
||||
/**
|
||||
* Auto-import public holidays for all active resources.
|
||||
*
|
||||
* For each resource, determines the applicable federal state from:
|
||||
* 1. resource.federalState (explicit, e.g. "BY")
|
||||
* 2. Falls back to federal-only holidays when no state is set
|
||||
*
|
||||
* Creates Vacation entries with type PUBLIC_HOLIDAY and status APPROVED.
|
||||
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
|
||||
*/
|
||||
|
||||
import { getPublicHolidays } from "@planarchy/shared";
|
||||
|
||||
interface MinimalVacation {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
interface AutoImportDb {
|
||||
resource: {
|
||||
findMany: (args: {
|
||||
where: { isActive: boolean };
|
||||
select: { id: string; federalState: string };
|
||||
}) => Promise<Array<{ id: string; federalState: string | null }>>;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: unknown) => Promise<MinimalVacation[]>;
|
||||
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutoImportResult {
|
||||
year: number;
|
||||
holidaysCreated: number;
|
||||
resourcesProcessed: number;
|
||||
skippedExisting: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import public holidays for all active resources in a given year.
|
||||
* Returns the number of holiday vacation records created.
|
||||
*/
|
||||
export async function autoImportPublicHolidays(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: any,
|
||||
year: number,
|
||||
): Promise<AutoImportResult> {
|
||||
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, federalState: true },
|
||||
});
|
||||
|
||||
if (resources.length === 0) {
|
||||
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
|
||||
}
|
||||
|
||||
// Group resources by federal state (null = federal-only holidays)
|
||||
const byState = new Map<string | null, string[]>();
|
||||
for (const resource of resources) {
|
||||
const state = resource.federalState ?? null;
|
||||
const group = byState.get(state) ?? [];
|
||||
group.push(resource.id);
|
||||
byState.set(state, group);
|
||||
}
|
||||
|
||||
let totalCreated = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const [state, resourceIds] of byState) {
|
||||
const holidays = getPublicHolidays(year, state ?? undefined);
|
||||
if (holidays.length === 0) continue;
|
||||
|
||||
for (const holiday of holidays) {
|
||||
const holidayDate = new Date(holiday.date);
|
||||
|
||||
// Find existing records for this date + type to skip duplicates
|
||||
const existing: MinimalVacation[] = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
},
|
||||
select: { resourceId: true, startDate: true, endDate: true },
|
||||
});
|
||||
|
||||
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
|
||||
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
|
||||
|
||||
totalSkipped += existingResourceIds.size;
|
||||
|
||||
if (newResourceIds.length === 0) continue;
|
||||
|
||||
const records = newResourceIds.map((resourceId) => ({
|
||||
resourceId,
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
status: "APPROVED",
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
note: holiday.name,
|
||||
isHalfDay: false,
|
||||
approvedAt: new Date(),
|
||||
}));
|
||||
|
||||
const result = await db.vacation.createMany({
|
||||
data: records,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
totalCreated += result.count;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
holidaysCreated: totalCreated,
|
||||
resourcesProcessed: resources.length,
|
||||
skippedExisting: totalSkipped,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user