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:
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user