ac845d72b7
Modal Overlay (Finding 1 — 6 admin files): - Migrated CountriesClient, ManagementLevelsClient, OrgUnitsClient, CalculationRulesClient, UtilizationCategoriesClient, RoleModal from inline fixed-overlay to AnimatedModal component - Gains: animated transitions, backdrop blur, escape key for free Notification Helper (Finding 9 — 9 API files, 14 call sites): - New createNotification() + createNotificationsForUsers() in packages/api/src/lib/create-notification.ts - Handles exactOptionalPropertyTypes spread + SSE emit internally - Simplified: budget-alerts, estimate-reminders, auto-staffing, vacation-conflicts, chargeability-alerts, comment, vacation, notification ConfirmDialog (Finding 3 — 11 files): - Replaced all window.confirm() calls with ConfirmDialog component - Files: CommentThread, EffortRules, ExperienceMultipliers, ManagementLevels, CalculationRules, Countries, RateCards, ApplyEffortRules, ApplyExperienceMultipliers, NotificationCenter, ReminderModal EntityCombobox (Finding 4 — 3 files): - New generic EntityCombobox<T> with customization hooks - ResourceCombobox + ProjectCombobox rewritten as thin wrappers - All consumers unchanged (backwards-compatible props) Proficiency Constants (Finding 2 — 2 files): - SkillsAnalytics + SkillMarketplace now import from skills/shared.tsx - Deleted ~70 LOC of local duplicate definitions Regression: 283 engine + 37 staffing tests pass. TypeScript clean. AI Assistant: all 87 tools verified accessible. Co-Authored-By: claude-flow <ruv@ruv.net>
259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
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 { createNotificationsForUsers } from "./create-notification.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;
|
|
|
|
await createNotificationsForUsers({
|
|
db: db as DbClient,
|
|
userIds: managers.map((m) => m.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",
|
|
});
|
|
|
|
alertCount++;
|
|
}
|
|
|
|
return alertCount;
|
|
}
|