Files
CapaKraken/packages/api/src/lib/chargeability-alerts.ts
T
Hartmut ac845d72b7 refactor: deduplicate modals, notifications, confirms, comboboxes, proficiency
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>
2026-03-22 21:50:39 +01:00

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;
}