Files
CapaKraken/packages/api/src/lib/vacation-conflicts.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

231 lines
6.2 KiB
TypeScript

import { VacationStatus } from "@planarchy/db";
import { createNotification } from "./create-notification.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) {
await createNotification({
db,
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",
});
}
}
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;
}