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
@@ -322,7 +322,11 @@ describe("estimate router", () => {
estimate: {
findUnique: vi
.fn()
// 1st call: resolve effectiveProjectId (rate card auto-fill)
.mockResolvedValueOnce({ projectId: null })
// 2nd call: application layer initial fetch
.mockResolvedValueOnce(baseEstimate)
// 3rd call: application layer post-update refetch
.mockResolvedValueOnce(updated),
update: updateEstimate,
},
+4
View File
@@ -4,3 +4,7 @@ export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationD
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
export { checkBudgetThresholds } from "./lib/budget-alerts.js";
export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js";
export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js";
export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js";
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js";
+251
View File
@@ -0,0 +1,251 @@
import { listAssignmentBookings } from "@planarchy/application";
import { rankResources } from "@planarchy/staffing";
import type { SkillEntry } from "@planarchy/shared";
import { emitNotificationCreated } from "../sse/event-bus.js";
/**
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
* Follows the same pattern as budget-alerts.ts.
*/
type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
demandRequirement: {
findUnique: (args: {
where: { id: string };
select: {
id: true;
projectId: true;
startDate: true;
endDate: true;
hoursPerDay: true;
role: true;
roleId: true;
headcount: true;
budgetCents: true;
};
}) => Promise<{
id: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role: string | null;
roleId: string | null;
headcount: number;
budgetCents: number;
} | null>;
};
project: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true };
}) => Promise<{ id: string; name: string } | null>;
};
role: {
findUnique: (args: {
where: { id: string };
select: { id: true; name: true };
}) => Promise<{ id: string; name: string } | null>;
};
resource: {
findMany: (args: {
where: { isActive: true };
}) => Promise<Array<{
id: string;
displayName: string;
eid: string | null;
skills: unknown;
lcrCents: number;
chargeabilityTarget: number;
availability: unknown;
valueScore: number | null;
}>>;
};
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 }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
const TOP_N = 3;
/**
* Generate automatic staffing suggestions for a demand requirement.
*
* Fetches the demand's role/dates/hours, runs the staffing ranking algorithm
* for the top 3 matches, and creates a notification for project managers
* with a summary of the suggestions.
*
* This function is designed to be called fire-and-forget (non-blocking).
* It swallows all errors to avoid disrupting the caller.
*/
export async function generateAutoSuggestions(
db: DbClient,
demandRequirementId: string,
): Promise<void> {
try {
// 1. Load the demand requirement
const demand = await db.demandRequirement.findUnique({
where: { id: demandRequirementId },
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
role: true,
roleId: true,
headcount: true,
budgetCents: true,
},
});
if (!demand) return;
// 2. Resolve project and role names
const [project, roleEntity] = await Promise.all([
db.project.findUnique({
where: { id: demand.projectId },
select: { id: true, name: true },
}),
demand.roleId
? db.role.findUnique({
where: { id: demand.roleId },
select: { id: true, name: true },
})
: Promise.resolve(null),
]);
if (!project) return;
const roleName = roleEntity?.name ?? demand.role ?? "Unspecified role";
// 3. Derive required skills from role name
// The role name itself is treated as the primary required skill.
// Resources with matching skill names in their skill matrix will rank highest.
const requiredSkills = [roleName];
// 4. Fetch all active resources and their current bookings
const resources = await db.resource.findMany({
where: { isActive: true },
});
if (resources.length === 0) return;
const bookings = await listAssignmentBookings(db, {
startDate: demand.startDate,
endDate: demand.endDate,
resourceIds: resources.map((r) => r.id),
});
// 5. Enrich resources with utilization data for the demand's date range
const enrichedResources = resources.map((resource) => {
const avail = resource.availability as
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
| null;
const totalAvailableHours = avail?.monday ?? 8;
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, b) => sum + b.hoursPerDay,
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity =
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
return {
id: resource.id,
displayName: resource.displayName,
eid: resource.eid,
skills: resource.skills as unknown as SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
valueScore: resource.valueScore ?? 0,
};
});
// 6. Rank resources using the staffing algorithm
const budgetLcrCentsPerHour =
demand.budgetCents > 0 ? demand.budgetCents : undefined;
const ranked = rankResources({
requiredSkills,
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
// Value-score tiebreaker (same logic as staffing router)
ranked.sort((a, b) => {
if (Math.abs(a.score - b.score) <= 2) {
const aVal = enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0;
const bVal = enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0;
return bVal - aVal;
}
return 0;
});
const topSuggestions = ranked.slice(0, TOP_N);
if (topSuggestions.length === 0) return;
// 7. Build notification message
const suggestionSummary = topSuggestions
.map((s) => `${s.resourceName} (${s.score}%)`)
.join(", ");
const title = `Staffing suggestions for ${roleName} on ${project.name}`;
const body = `${topSuggestions.length} matching resources found for ${roleName} on ${project.name}: ${suggestionSummary}`;
// 8. Notify all managers and admins
const managers = await db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
for (const manager of managers) {
const notification = await db.notification.create({
data: {
userId: manager.id,
type: "AUTO_STAFFING_SUGGESTION",
category: "NOTIFICATION",
priority: "NORMAL",
title,
body,
entityId: demandRequirementId,
entityType: "demand",
link: `/staffing?demandId=${demandRequirementId}`,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
} catch {
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
}
}
@@ -0,0 +1,263 @@
import {
deriveResourceForecast,
getMonthRange,
countWorkingDaysInOverlap,
calculateSAH,
type AssignmentSlice,
} from "@planarchy/engine";
import type { SpainScheduleRule } from "@planarchy/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { emitNotificationCreated } from "../sse/event-bus.js";
/**
* Minimal DB client type for chargeability alerts.
* Uses structural typing so we can pass in `prisma as any` from the cron route.
*/
type DbClient = {
resource: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, unknown>;
}) => Promise<
Array<{
id: string;
displayName: string;
fte: number;
chargeabilityTarget: number;
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
managementLevelGroup: { targetPercentage: number | null } | null;
}>
>;
};
vacation: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, unknown>;
}) => Promise<
Array<{
resourceId: string;
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
}>
>;
};
notification: {
findFirst: (args: {
where: Record<string, unknown>;
select: { id: true };
}) => Promise<{ id: string } | null>;
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 }>;
};
user: {
findMany: (args: {
where: { systemRole: { in: string[] } };
select: { id: true };
}) => Promise<Array<{ id: string }>>;
};
};
/** Alert when chargeability is more than 15pp below target */
const GAP_THRESHOLD_PP = 15;
/**
* Find resources whose current-month chargeability is >15 percentage points
* below their target, and create a notification for all managers.
*
* Duplicate-safe: skips resources that already have an alert this month.
*
* Returns the number of new alerts created.
*/
export async function checkChargeabilityAlerts(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
): Promise<number> {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1;
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const monthKey = `${year}-${String(month).padStart(2, "0")}`;
// Fetch active, chg-responsible resources
const resources = await (db as DbClient).resource.findMany({
where: {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
},
select: {
id: true,
displayName: true,
fte: true,
chargeabilityTarget: true,
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { targetPercentage: true } },
},
});
if (resources.length === 0) return 0;
const resourceIds = resources.map((r) => r.id);
// Fetch bookings for the current month
const allBookings = await listAssignmentBookings(db, {
startDate: monthStart,
endDate: monthEnd,
resourceIds,
});
// Fetch vacations for the current month
const vacations = await (db as DbClient).vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
// Compute chargeability per resource
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
for (const resource of resources) {
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
// Compute absence dates for SAH
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
const absenceDates: string[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
absenceDates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [],
absenceDays: absenceDates,
});
// Build assignment slices
const resourceBookings = allBookings.filter(
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
);
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
return {
hoursPerDay: b.hoursPerDay,
workingDays,
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
};
});
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
});
const chgPct = forecast.chg * 100;
const targetPctVal = targetPct * 100;
const gap = targetPctVal - chgPct;
if (gap > GAP_THRESHOLD_PP) {
underperformers.push({
resource,
chg: Math.round(chgPct),
target: Math.round(targetPctVal),
gap: Math.round(gap),
});
}
}
if (underperformers.length === 0) return 0;
// Fetch managers to notify
const managers = await (db as DbClient).user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
if (managers.length === 0) return 0;
let alertCount = 0;
for (const { resource, chg, target, gap } of underperformers) {
// Duplicate check: one alert per resource per month
const entityId = `chg-alert-${resource.id}-${monthKey}`;
const existing = await (db as DbClient).notification.findFirst({
where: {
entityId,
entityType: "chargeability_alert",
type: "CHARGEABILITY_ALERT",
},
select: { id: true },
});
if (existing) continue;
for (const manager of managers) {
const notification = await (db as DbClient).notification.create({
data: {
userId: manager.id,
type: "CHARGEABILITY_ALERT",
category: "NOTIFICATION",
priority: "HIGH",
title: `Low chargeability: ${resource.displayName}`,
body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`,
entityId,
entityType: "chargeability_alert",
link: "/chargeability",
channel: "in_app",
},
});
emitNotificationCreated(manager.id, notification.id);
}
alertCount++;
}
return alertCount;
}
+121
View File
@@ -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,
};
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Rate card lookup logic for auto-filling demand line rates.
*
* Match priority (highest specificity wins):
* 1. Exact client + chapter + role
* 2. Client + chapter (any role)
* 3. Client + role (any chapter)
* 4. Client only (fallback)
* 5. Default rate card (no client) + best match
*
* Within each priority tier, additional criteria (seniority, location,
* workType) increase the score.
*/
export interface RateCardLookupParams {
clientId?: string | null;
chapter?: string | null;
roleId?: string | null;
seniority?: string | null;
location?: string | null;
workType?: string | null;
effectiveDate?: Date | null;
}
export interface RateCardLookupResult {
costRateCents: number;
billRateCents: number;
currency: string;
rateCardId: string;
rateCardLineId: string;
rateCardName: string;
}
interface RateCardLineRow {
id: string;
rateCardId: string;
roleId: string | null;
chapter: string | null;
location: string | null;
seniority: string | null;
workType: string | null;
costRateCents: number;
billRateCents: number | null;
rateCard: {
id: string;
name: string;
currency: string;
clientId: string | null;
};
}
/**
* Look up the best-matching rate card line for a given set of criteria.
* Returns null when no active rate card line matches.
*/
export async function lookupRate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
params: RateCardLookupParams,
): Promise<RateCardLookupResult | null> {
const effectiveDate = params.effectiveDate ?? new Date();
// Build rate card filter: active cards, within effective date range
const rateCardWhere: Record<string, unknown> = {
isActive: true,
OR: [
{ effectiveFrom: null },
{ effectiveFrom: { lte: effectiveDate } },
],
AND: [
{
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: effectiveDate } },
],
},
],
};
// If we have a clientId, look for both client-specific and default (null client) cards
if (params.clientId) {
rateCardWhere.clientId = { in: [params.clientId, null] };
}
// If no clientId, only look at default (null client) cards
// (don't pass clientId filter at all to keep the OR above valid)
const lines = (await db.rateCardLine.findMany({
where: {
rateCard: rateCardWhere,
},
select: {
id: true,
rateCardId: true,
roleId: true,
chapter: true,
location: true,
seniority: true,
workType: true,
costRateCents: true,
billRateCents: true,
rateCard: {
select: {
id: true,
name: true,
currency: true,
clientId: true,
},
},
},
})) as RateCardLineRow[];
if (lines.length === 0) return null;
// Score each line. Higher = better match.
type ScoredLine = { line: RateCardLineRow; score: number; mismatch: boolean };
const scored: ScoredLine[] = lines.map((line) => {
let score = 0;
let mismatch = false;
// Client specificity: client-specific cards get a large bonus
if (params.clientId && line.rateCard.clientId === params.clientId) {
score += 100;
} else if (params.clientId && line.rateCard.clientId != null) {
// Different client entirely => disqualify
mismatch = true;
}
// Default card (null client) gets no bonus but is a valid fallback
// Role match
if (params.roleId && line.roleId) {
if (line.roleId === params.roleId) score += 16;
else mismatch = true;
}
// Chapter match
if (params.chapter && line.chapter) {
if (line.chapter === params.chapter) score += 8;
else mismatch = true;
}
// Seniority match
if (params.seniority && line.seniority) {
if (line.seniority === params.seniority) score += 4;
else mismatch = true;
}
// Location match
if (params.location && line.location) {
if (line.location === params.location) score += 2;
else mismatch = true;
}
// Work type match
if (params.workType && line.workType) {
if (line.workType === params.workType) score += 1;
else mismatch = true;
}
return { line, score, mismatch };
});
// Filter out mismatched lines and sort by score descending
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
if (!best) return null;
return {
costRateCents: best.line.costRateCents,
billRateCents: best.line.billRateCents ?? 0,
currency: best.line.rateCard.currency,
rateCardId: best.line.rateCard.id,
rateCardLineId: best.line.id,
rateCardName: best.line.rateCard.name,
};
}
+232
View File
@@ -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;
}
+11
View File
@@ -31,6 +31,7 @@ import { findUniqueOrThrow } from "../db/helpers.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkBudgetThresholds } from "../lib/budget-alerts.js";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
@@ -495,6 +496,9 @@ export const allocationRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId);
// Fire-and-forget: compute and notify top-3 staffing suggestions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void generateAutoSuggestions(ctx.db as any, demandRequirement.id);
return demandRequirement;
}),
@@ -631,6 +635,13 @@ export const allocationRouter = createTRPCRouter({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void checkBudgetThresholds(ctx.db as any, result.assignment.projectId);
// If there are still unfilled slots, refresh suggestions for remaining demand
if (result.updatedDemandRequirement.headcount > 0
&& result.updatedDemandRequirement.status !== "COMPLETED") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void generateAutoSuggestions(ctx.db as any, result.updatedDemandRequirement.id);
}
return result;
}),
+140 -2
View File
@@ -37,6 +37,7 @@ import {
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { lookupRate } from "../lib/rate-card-lookup.js";
import {
controllerProcedure,
createTRPCRouter,
@@ -142,6 +143,75 @@ function withComputedMetrics<
};
}
/**
* Auto-fill rate card rates into demand lines that have default (zero) rates.
* A line is eligible for auto-fill when both costRateCents and billRateCents
* are 0 (the Zod default) and rateSource is not explicitly set.
*
* Returns the enriched demand lines and a list of line indices that were auto-filled.
*/
async function autoFillDemandLineRates(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
projectId?: string | null,
): Promise<{
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
autoFilledIndices: number[];
}> {
// Resolve clientId from the linked project
let clientId: string | null = null;
if (projectId) {
const project = await db.project.findUnique({
where: { id: projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const autoFilledIndices: number[] = [];
const enriched = await Promise.all(
demandLines.map(async (line, index) => {
// Only auto-fill if both rates are at default (0) and no explicit rateSource
const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;
if (!isDefaultRate || hasExplicitSource) return line;
const result = await lookupRate(db, {
clientId,
chapter: line.chapter ?? null,
roleId: line.roleId ?? null,
});
if (!result) return line;
autoFilledIndices.push(index);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
return {
...line,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateSource: `rate-card:${result.rateCardId}`,
metadata: {
...existingMetadata,
autoAppliedRateCard: {
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
appliedAt: new Date().toISOString(),
},
},
};
}),
);
return { demandLines: enriched, autoFilledIndices };
}
export const estimateRouter = createTRPCRouter({
list: protectedProcedure
.input(EstimateListFiltersSchema.default({}))
@@ -180,9 +250,14 @@ export const estimateRouter = createTRPCRouter({
);
}
// Auto-fill rates from rate cards for demand lines with default (zero) rates
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(input, input.baseCurrency),
withComputedMetrics(enrichedInput, input.baseCurrency),
);
await ctx.db.auditLog.create({
@@ -198,6 +273,7 @@ export const estimateRouter = createTRPCRouter({
status: estimate.status,
projectId: estimate.projectId,
latestVersionNumber: estimate.latestVersionNumber,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
@@ -263,11 +339,25 @@ export const estimateRouter = createTRPCRouter({
);
}
// Auto-fill rates from rate cards for demand lines with default (zero) rates
// Resolve projectId: explicit input or existing estimate's projectId
let effectiveProjectId = input.projectId;
if (!effectiveProjectId) {
const existing = await ctx.db.estimate.findUnique({
where: { id: input.id },
select: { projectId: true },
});
effectiveProjectId = existing?.projectId ?? undefined;
}
const { demandLines: enrichedLines, autoFilledIndices } =
await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
const enrichedInput = { ...input, demandLines: enrichedLines };
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(input, input.baseCurrency ?? "EUR"),
withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
@@ -300,6 +390,7 @@ export const estimateRouter = createTRPCRouter({
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
autoFilledRateCardLines: autoFilledIndices.length,
},
} as Prisma.InputJsonValue,
},
@@ -837,4 +928,51 @@ export const estimateRouter = createTRPCRouter({
return { versionId: version.id, terms: validated };
}),
// ─── Rate Card Lookup for Demand Lines ──────────────────────────────────
lookupDemandLineRate: controllerProcedure
.input(z.object({
projectId: z.string().optional(),
clientId: z.string().optional(),
roleId: z.string().optional(),
chapter: z.string().optional(),
seniority: z.string().optional(),
location: z.string().optional(),
workType: z.string().optional(),
effectiveDate: z.coerce.date().optional(),
}))
.query(async ({ ctx, input }) => {
// Resolve clientId from project if not provided directly
let clientId = input.clientId ?? null;
if (!clientId && input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { clientId: true },
});
clientId = project?.clientId ?? null;
}
const result = await lookupRate(ctx.db, {
clientId,
chapter: input.chapter ?? null,
roleId: input.roleId ?? null,
seniority: input.seniority ?? null,
location: input.location ?? null,
workType: input.workType ?? null,
effectiveDate: input.effectiveDate ?? null,
});
if (!result) return { found: false as const };
return {
found: true as const,
costRateCents: result.costRateCents,
billRateCents: result.billRateCents,
currency: result.currency,
rateCardId: result.rateCardId,
rateCardLineId: result.rateCardLineId,
rateCardName: result.rateCardName,
};
}),
});
+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,
};
}),
});
+21 -2
View File
@@ -8,6 +8,7 @@ import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emit
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
@@ -277,6 +278,10 @@ export const vacationRouter = createTRPCRouter({
select: { id: true },
});
// Check for team conflicts before approving (non-blocking)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const conflictResult = await checkVacationConflicts(ctx.db as any, input.id, userRecord?.id);
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
@@ -307,7 +312,7 @@ export const vacationRouter = createTRPCRouter({
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return updated;
return { ...updated, warnings: conflictResult.warnings };
}),
/**
@@ -373,6 +378,14 @@ export const vacationRouter = createTRPCRouter({
select: { id: true, resourceId: true },
});
// Check for team conflicts before approving (non-blocking)
const conflictMap = await checkBatchVacationConflicts(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ctx.db as any,
vacations.map((v) => v.id),
userRecord?.id,
);
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((v) => v.id) } },
data: {
@@ -402,7 +415,13 @@ export const vacationRouter = createTRPCRouter({
});
}
return { approved: vacations.length };
// Flatten all warnings into a single array
const warnings: string[] = [];
for (const [, w] of conflictMap) {
warnings.push(...w);
}
return { approved: vacations.length, warnings };
}),
/**