feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+70 -38
View File
@@ -21,6 +21,7 @@ import {
FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema,
PermissionKey,
type WeekdayAvailability,
UpdateAssignmentSchema,
UpdateAllocationSchema,
UpdateDemandRequirementSchema,
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.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 {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
@@ -328,12 +336,26 @@ export const allocationRouter = createTRPCRouter({
where: { id: input.resourceId },
select: {
id: true, displayName: true, eid: true, fte: true,
country: { select: { dailyWorkingHours: true } },
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { dailyWorkingHours: true, code: true } },
metroCity: { select: { name: true } },
},
});
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const availability = (resource.availability as WeekdayAvailability | null) ?? {
monday: fallbackDailyHours,
tuesday: fallbackDailyHours,
wednesday: fallbackDailyHours,
thursday: fallbackDailyHours,
friday: fallbackDailyHours,
saturday: 0,
sunday: 0,
};
// Get existing assignments in the date range
const existingAssignments = await ctx.db.assignment.findMany({
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
orderBy: { startDate: "asc" },
});
// Get vacations in the date range
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: "APPROVED",
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
// Calculate day-by-day availability
let totalWorkingDays = 0;
const totalWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
let availableDays = 0;
let conflictDays = 0;
let partialDays = 0;
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
const d = new Date(input.startDate);
const end = new Date(input.endDate);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) {
totalWorkingDays++;
const effectiveDayCapacity = calculateEffectiveDayAvailability({
availability,
date: d,
context,
});
// Check vacation
const isVacation = vacations.some((v) => {
const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0);
const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0);
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
return dc >= vs && dc <= ve;
});
if (isVacation) {
conflictDays++;
d.setDate(d.getDate() + 1);
continue;
}
// Sum existing hours on this day
if (effectiveDayCapacity > 0) {
let bookedHours = 0;
for (const a of existingAssignments) {
const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0);
const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0);
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
if (dc >= as2 && dc <= ae) {
bookedHours += a.hoursPerDay;
}
bookedHours += calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: d,
periodEnd: d,
context,
});
}
const remainingCapacity = Math.max(0, dailyCapacity - bookedHours);
const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
if (remainingCapacity >= requestedHpd) {
availableDays++;
totalAvailableHours += requestedHpd;
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
}
const totalRequestedHours = totalWorkingDays * requestedHpd;
const totalPeriodCapacity = calculateEffectiveAvailableHours({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const dailyCapacity = totalWorkingDays > 0
? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10
: 0;
return {
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
@@ -0,0 +1,243 @@
export interface AssistantInsightMetric {
label: string;
value: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
}
export interface AssistantInsightSection {
title: string;
metrics: AssistantInsightMetric[];
}
export interface AssistantInsight {
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
title: string;
subtitle?: string;
metrics: AssistantInsightMetric[];
sections?: AssistantInsightSection[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function formatHours(value: unknown): string | null {
const num = asNumber(value);
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} h`;
}
function formatDays(value: unknown): string | null {
const num = asNumber(value);
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} d`;
}
function pushMetric(
metrics: AssistantInsightMetric[],
label: string,
value: string | null,
tone?: AssistantInsightMetric["tone"],
) {
if (!value) return;
metrics.push({ label, value, ...(tone ? { tone } : {}) });
}
function createLocationLabel(locationContext: Record<string, unknown> | undefined): string | null {
if (!locationContext) return null;
const parts = [
asString(locationContext.metroCity),
asString(locationContext.federalState),
asString(locationContext.country),
asString(locationContext.countryCode),
].filter(Boolean);
return parts.length > 0 ? parts.join(", ") : null;
}
function buildChargeabilityInsight(data: Record<string, unknown>): AssistantInsight | null {
const resource = asString(data.resource);
const month = asString(data.month);
if (!resource || !month) return null;
const holidaySummary = isRecord(data.holidaySummary) ? data.holidaySummary : undefined;
const absenceSummary = isRecord(data.absenceSummary) ? data.absenceSummary : undefined;
const capacityBreakdown = isRecord(data.capacityBreakdown) ? data.capacityBreakdown : undefined;
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
const chargeabilityPct = asNumber(data.chargeabilityPct);
const targetPct = asNumber(data.targetPct);
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Chargeability", asString(data.chargeability), chargeabilityPct == null || targetPct == null
? "info"
: chargeabilityPct >= targetPct ? "good" : "warn");
pushMetric(metrics, "Available", formatHours(data.availableHours));
pushMetric(metrics, "Booked", formatHours(data.bookedHours));
pushMetric(metrics, "Unassigned", formatHours(data.unassignedHours));
pushMetric(metrics, "Target", formatHours(data.targetHours));
pushMetric(metrics, "Holidays", formatDays(holidaySummary?.workdayCount ?? holidaySummary?.count));
const sections: AssistantInsightSection[] = [];
const basisMetrics: AssistantInsightMetric[] = [];
pushMetric(basisMetrics, "Location", createLocationLabel(locationContext), "info");
pushMetric(basisMetrics, "Base working days", formatDays(data.baseWorkingDays));
pushMetric(basisMetrics, "Effective working days", formatDays(data.workingDays));
pushMetric(basisMetrics, "Base capacity", formatHours(data.baseAvailableHours));
if (basisMetrics.length > 0) {
sections.push({ title: "Basis", metrics: basisMetrics });
}
const deductionMetrics: AssistantInsightMetric[] = [];
pushMetric(deductionMetrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction ?? capacityBreakdown?.holidayHoursDeduction), "warn");
pushMetric(deductionMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
pushMetric(deductionMetrics, "Absence days", formatDays(absenceSummary?.dayEquivalent));
if (deductionMetrics.length > 0) {
sections.push({ title: "Deductions", metrics: deductionMetrics });
}
return {
kind: "chargeability",
title: `${resource} · ${month}`,
subtitle: "Holiday-aware monthly capacity",
metrics,
...(sections.length > 0 ? { sections } : {}),
};
}
function buildHolidayRegionInsight(data: Record<string, unknown>): AssistantInsight | null {
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
const periodStart = asString(data.periodStart);
const periodEnd = asString(data.periodEnd);
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Region", createLocationLabel(locationContext), "info");
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
const summary = isRecord(data.summary) ? data.summary : undefined;
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
const scopeMetrics = scopeItems
.map((item) => {
if (!isRecord(item)) return null;
const scope = asString(item.scope);
const count = asNumber(item.count);
if (!scope || count == null) return null;
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
})
.filter((item): item is AssistantInsightMetric => item !== null);
return {
kind: "holiday_region",
title: createLocationLabel(locationContext) ?? "Regional holidays",
subtitle: "Resolved public holiday set",
metrics,
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
};
}
function buildResourceHolidayInsight(data: Record<string, unknown>): AssistantInsight | null {
const resource = isRecord(data.resource) ? data.resource : undefined;
const summary = isRecord(data.summary) ? data.summary : undefined;
const periodStart = asString(data.periodStart);
const periodEnd = asString(data.periodEnd);
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Employee", asString(resource?.name) ?? asString(resource?.eid));
pushMetric(metrics, "Location", createLocationLabel(resource), "info");
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
const scopeMetrics = scopeItems
.map((item) => {
if (!isRecord(item)) return null;
const scope = asString(item.scope);
const count = asNumber(item.count);
if (!scope || count == null) return null;
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
})
.filter((item): item is AssistantInsightMetric => item !== null);
return {
kind: "resource_holidays",
title: `${asString(resource?.name) ?? "Resource"} holidays`,
subtitle: "Location-specific holiday resolution",
metrics,
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
};
}
function buildResourceMatchInsight(data: Record<string, unknown>): AssistantInsight | null {
const project = isRecord(data.project) ? data.project : undefined;
const period = isRecord(data.period) ? data.period : undefined;
const bestMatch = isRecord(data.bestMatch) ? data.bestMatch : undefined;
if (!project || !period || !bestMatch) return null;
const remainingHours = asNumber(bestMatch.remainingHours);
const remainingHoursPerDay = asNumber(bestMatch.remainingHoursPerDay);
const lcr = asString(bestMatch.lcr);
const holidaySummary = isRecord(bestMatch.holidaySummary) ? bestMatch.holidaySummary : undefined;
const absenceSummary = isRecord(bestMatch.absenceSummary) ? bestMatch.absenceSummary : undefined;
const capacityBreakdown = isRecord(bestMatch.capacityBreakdown) ? bestMatch.capacityBreakdown : undefined;
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Best match", asString(bestMatch.name) ?? asString(bestMatch.eid), "good");
pushMetric(metrics, "Project", asString(project.name) ?? asString(project.shortCode));
pushMetric(metrics, "Remaining", formatHours(remainingHours), remainingHours != null && remainingHours > 0 ? "good" : "warn");
pushMetric(metrics, "Per workday", formatHours(remainingHoursPerDay));
pushMetric(metrics, "LCR", lcr);
pushMetric(metrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction), "warn");
const sections: AssistantInsightSection[] = [];
const profileMetrics: AssistantInsightMetric[] = [];
pushMetric(profileMetrics, "Role", asString(bestMatch.role));
pushMetric(profileMetrics, "Chapter", asString(bestMatch.chapter));
pushMetric(profileMetrics, "Location", createLocationLabel(bestMatch), "info");
pushMetric(profileMetrics, "Candidate pool", asNumber(data.candidateCount)?.toString() ?? null);
if (profileMetrics.length > 0) {
sections.push({ title: "Selection", metrics: profileMetrics });
}
const basisMetrics: AssistantInsightMetric[] = [];
pushMetric(basisMetrics, "Window", asString(period.startDate) && asString(period.endDate) ? `${asString(period.startDate)} to ${asString(period.endDate)}` : null);
pushMetric(basisMetrics, "Ranking", asString(period.rankingMode));
pushMetric(basisMetrics, "Min/day", formatHours(period.minHoursPerDay));
pushMetric(basisMetrics, "Base capacity", formatHours(capacityBreakdown?.baseAvailableHours ?? bestMatch.baseAvailableHours));
pushMetric(basisMetrics, "Effective capacity", formatHours(bestMatch.availableHours));
pushMetric(basisMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
if (basisMetrics.length > 0) {
sections.push({ title: "Capacity basis", metrics: basisMetrics });
}
return {
kind: "resource_match",
title: `${asString(project.shortCode) ?? asString(project.name) ?? "Project"} staffing`,
subtitle: "Holiday-aware best-fit resource",
metrics,
...(sections.length > 0 ? { sections } : {}),
};
}
export function buildAssistantInsight(toolName: string, data: unknown): AssistantInsight | null {
if (!isRecord(data)) return null;
switch (toolName) {
case "get_chargeability":
return buildChargeabilityInsight(data);
case "find_best_project_resource":
return buildResourceMatchInsight(data);
case "list_holidays_by_region":
return buildHolidayRegionInsight(data);
case "get_resource_holidays":
return buildResourceHolidayInsight(data);
default:
return null;
}
}
File diff suppressed because it is too large Load Diff
+51 -20
View File
@@ -5,10 +5,11 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
import { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js";
import { createAuditEntry } from "../lib/audit.js";
@@ -20,7 +21,7 @@ const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-A
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Chargeability-Analysen, Urlaubsübersichten, Feiertagskalender nach Land/Bundesland/Stadt, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
@@ -40,6 +41,12 @@ Wichtige Regeln:
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
- Wenn Feiertage, SAH, Chargeability, Verfügbarkeit oder Ressourcenauswahl relevant sind, erkläre IMMER transparent:
1. Standortkontext (Land/Bundesland/Stadt falls relevant)
2. Feiertagsbasis bzw. Feiertagsanzahl
3. Abzüge durch Feiertage/Abwesenheiten
4. resultierende verfügbare Stunden / Zielstunden / Restkapazität
- Wenn strukturierte UI-Karten vorhanden sind, wiederhole dort gezeigte Zahlen NICHT vollständig im Freitext. Gib nur die Kernaussage und die wichtigste Begründung an.
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
Datenmodell:
@@ -48,10 +55,12 @@ Datenmodell:
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
`;
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = {
list_users: PermissionKey.MANAGE_USERS,
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
@@ -89,7 +98,36 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
};
/** Tools that require cost visibility */
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
return TOOL_DEFINITIONS.filter((tool) => {
const toolName = tool.function.name;
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
return false;
}
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
});
}
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
if (duplicateIndex >= 0) {
const copy = [...existing];
copy[duplicateIndex] = next;
return copy;
}
return [...existing, next].slice(-6);
}
export const assistantRouter = createTRPCRouter({
chat: protectedProcedure
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
}
// 4. Filter tools based on granular permissions
const availableTools = TOOL_DEFINITIONS.filter((t) => {
const toolName = t.function.name;
// Check write permission
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as import("@capakraken/shared").PermissionKey)) {
return false;
}
// Hide cost/budget tools if user lacks viewCosts
if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@capakraken/shared").PermissionKey)) {
return false;
}
return true;
});
const availableTools = getAvailableAssistantTools(permissions);
// 5. Function calling loop
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
const collectedActions: ToolAction[] = [];
let collectedInsights: AssistantInsight[] = [];
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
toolCtx,
);
const insight = buildAssistantInsight(toolCall.function.name, result.data);
if (insight) {
collectedInsights = mergeInsights(collectedInsights, insight);
}
// Collect any actions (e.g. navigation)
if (result.action) {
collectedActions.push(result.action);
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
return {
content: finalContent,
role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
return {
content: "I had to stop after too many tool calls. Please try a simpler question.",
role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}),
+54 -130
View File
@@ -5,19 +5,18 @@ import {
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
calculateAllocation,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
import type { SpainScheduleRule } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
},
}));
// Fetch vacations/absences in the range (including type for rules engine)
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: rangeEnd },
endDate: { gte: rangeStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
// Load calculation rules for chargeability adjustments
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
// Build per-resource, per-month forecasts
const resourceRows = resources.map((resource) => {
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = monthKeys.map((key) => {
const months = await Promise.all(monthKeys.map(async (key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
// Compute absence days for SAH
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);
}
}
// Calculate SAH for this resource+month
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [], // TODO: integrate public holidays from country
absenceDays: absenceDates,
context,
});
// Build typed absence days for this resource in this month
const monthAbsenceDays: AbsenceDay[] = [];
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 absCursor = new Date(vStart);
absCursor.setUTCHours(0, 0, 0, 0);
const absEndNorm = new Date(vEnd);
absEndNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (absCursor <= absEndNorm) {
monthAbsenceDays.push({
date: new Date(absCursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
}
// Build assignment slices for this month, using rules to compute chargeable hours
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
// If there are absences and rules, compute rules-adjusted chargeable hours
if (monthAbsenceDays.length > 0) {
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const calcResult = calculateAllocation({
lcrCents: 0, // we only need hours, not costs
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
absenceDays: monthAbsenceDays,
calculationRules: calcRules,
});
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
});
} else {
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
}
return {
hoursPerDay: a.hoursPerDay,
workingDays: 0,
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
totalChargeableHours,
};
});
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
sah: availableHours,
});
return {
monthKey: key,
sah: sahResult.standardAvailableHours,
sah: availableHours,
...forecast,
};
});
}));
return {
id: resource.id,
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
targetPct,
months,
};
});
}));
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
+205 -70
View File
@@ -4,18 +4,27 @@ import {
deriveResourceForecast,
computeBudgetStatus,
getMonthRange,
countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines,
computeEvenSpread,
distributeHoursToWeeks,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
import {
asHolidayResolverDb,
collectHolidayAvailability,
getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
calculateEffectiveAvailableHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
// ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
return v.toFixed(decimals);
}
function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
return availability[dayKey] ?? 0;
}
function sumAvailabilityHoursForDates(
availability: WeekdayAvailability,
dates: Date[],
): number {
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}
// ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
fte: true,
lcrCents: true,
chargeabilityTarget: true,
countryId: true,
federalState: true,
metroCityId: true,
availability: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
metroCity: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
});
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
},
});
// ── 3. Load absences ──
// ── 3. Load absences + holiday context ──
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: monthStart,
periodEnd: monthEnd,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
const holidayAvailability = collectHolidayAvailability({
vacations,
periodStart: monthStart,
periodEnd: monthEnd,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name,
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
});
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
const absenceDateStrings = holidayAvailability.absenceDateStrings;
const absenceDays = holidayAvailability.absenceDays;
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
const publicHolidayCount = resolvedHolidays.length;
// Build absence dates for SAH (ISO strings), separating public holidays
const publicHolidayStrings: string[] = [];
const absenceDateStrings: string[] = [];
const absenceDays: AbsenceDay[] = [];
let halfDayCount = 0;
let vacationDayCount = 0;
let sickDayCount = 0;
let publicHolidayCount = 0;
for (const v of vacations) {
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);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cursor <= endNorm) {
const isoDate = cursor.toISOString().slice(0, 10);
if (triggerType === "PUBLIC_HOLIDAY") {
publicHolidayStrings.push(isoDate);
publicHolidayCount++;
} else {
absenceDateStrings.push(isoDate);
if (triggerType === "VACATION") vacationDayCount++;
if (triggerType === "SICK") sickDayCount++;
}
absenceDays.push({
date: new Date(cursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
if (v.isHalfDay) halfDayCount++;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
monthStart,
monthEnd,
);
const availabilityContext = contexts.get(resource.id);
// ── 4. Load calculation rules ──
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
// table may not exist yet
}
// ── 5. Calculate SAH ──
// ── 5. Calculate SAH / effective capacity ──
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings,
});
const baseWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
weeklyAvailability,
publicHolidayDates,
);
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
}, 0);
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
? effectiveAvailableHours / effectiveWorkingDays
: 0;
const holidayScopeSummary = [
resource.country?.code ?? "—",
resource.federalState ?? "—",
resource.metroCity?.name ?? "—",
].join(" / ");
const holidayExamples = resolvedHolidays.length > 0
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
: "none";
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
return counts;
}, {});
// ── 6. Calculate allocations + chargeability slices ──
const slices: AssignmentSlice[] = [];
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
let hasRulesEffect = false;
for (const a of assignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
absenceDays,
calculationRules: calcRules,
});
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
workingDays: calcResult.workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
sah: effectiveAvailableHours,
});
// ── 8. Build budget status for first project with budget ──
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const calcResult = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
});
return sum + calcResult.workingDays;
}, 0);
// Format weekly availability for display
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
// Derived utilization ratio
const utilizationPct = sahResult.standardAvailableHours > 0
? (totalAllocHours / sahResult.standardAvailableHours) * 100
const utilizationPct = effectiveAvailableHours > 0
? (totalAllocHours / effectiveAvailableHours) * 100
: 0;
const chargeableHours = forecast.chg * effectiveAvailableHours;
// Has schedule rules (Spain variable hours)?
const hasScheduleRules = !!scheduleRules;
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
const nodes: GraphNode[] = [
// INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
...(hasScheduleRules ? [
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
// SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
// ALLOCATION
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
] : []),
// CHARGEABILITY — full breakdown from deriveResourceForecast
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
...(forecast.bd > 0 ? [
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
] : []),
...(forecast.mdi > 0 ? [
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
] : []),
...(forecast.mo > 0 ? [
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
] : []),
...(forecast.pdr > 0 ? [
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
] : []),
...(forecast.absence > 0 ? [
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
] : []),
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability target"),
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
const links: GraphLink[] = [
// INPUT → SAH
l("input.country", "input.holidayContext", "holiday base", 1),
l("input.state", "input.holidayContext", "regional scope", 1),
l("input.city", "input.holidayContext", "local scope", 1),
l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
...(hasScheduleRules ? [
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
] : []),
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
l("sah.grossWorkingDays", "sah.netWorkingDays", "", 2),
l("sah.grossWorkingDays", "sah.netWorkingDays", " holiday/absence days", 2),
l("sah.publicHolidayDays", "sah.netWorkingDays", "", 1),
l("sah.absenceDays", "sah.netWorkingDays", "", 1),
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
l("sah.baseHours", "sah.sah", "start from base capacity", 2),
l("sah.publicHolidayHours", "sah.sah", " holiday hours", 2),
l("sah.absenceHours", "sah.sah", " absence hours", 2),
l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
l("sah.netWorkingDays", "sah.sah", "×", 2),
// INPUT → ALLOCATION
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
resourceEid: resource.eid,
month: input.month,
assignmentCount: assignments.length,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
resolvedHolidays: resolvedHolidays.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName,
})),
factors: {
weeklyAvailability,
baseWorkingDays,
effectiveWorkingDays,
baseAvailableHours,
effectiveAvailableHours,
publicHolidayCount,
publicHolidayWorkdayCount,
publicHolidayHoursDeduction,
absenceDayCount: absenceDateStrings.length,
absenceHoursDeduction,
chargeableHours,
utilizationPct,
},
},
};
}),
+83 -17
View File
@@ -9,19 +9,19 @@ import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
/**
* Count calendar days between two dates (inclusive).
* Half-day vacations count as 0.5.
*/
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
if (isHalfDay) return 0.5;
const ms = endDate.getTime() - startDate.getTime();
return Math.round(ms / 86_400_000) + 1;
}
type EntitlementSnapshot = {
id: string;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
};
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
return entitlement;
}
function calculateCarryoverDays(entitlement: {
entitledDays: number;
usedDays: number;
pendingDays: number;
}): number {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
@@ -69,14 +77,57 @@ async function syncEntitlement(
resourceId: string,
year: number,
defaultDays: number,
) {
visitedYears: Set<number> = new Set(),
): Promise<EntitlementSnapshot> {
if (visitedYears.has(year)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Detected recursive entitlement sync for year ${year}`,
});
}
visitedYears.add(year);
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
if (previousYearEntitlement) {
previousYearEntitlement = await syncEntitlement(
db,
resourceId,
year - 1,
defaultDays,
visitedYears,
);
}
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const carryoverDays = previousYearEntitlement
? calculateCarryoverDays(previousYearEntitlement)
: 0;
const expectedEntitledDays = defaultDays + carryoverDays;
const entitlementWithCarryover = (
entitlement.carryoverDays !== carryoverDays
|| entitlement.entitledDays !== expectedEntitledDays
)
? await db.vacationEntitlement.update({
where: { id: entitlement.id },
data: {
carryoverDays,
entitledDays: expectedEntitledDays,
},
})
: entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
@@ -86,13 +137,22 @@ async function syncEntitlement(
let pendingDays = 0;
for (const v of vacations) {
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
const days = countVacationChargeableDays({
vacation: v,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
where: { id: entitlement.id },
where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacations = await ctx.db.vacation.findMany({
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
(sum, v) => sum + countCalendarDaysInPeriod(
v,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
}),
/**
+471
View File
@@ -0,0 +1,471 @@
import {
CreateHolidayCalendarEntrySchema,
CreateHolidayCalendarSchema,
type HolidayCalendarScopeInput,
PreviewResolvedHolidaysSchema,
UpdateHolidayCalendarEntrySchema,
UpdateHolidayCalendarSchema,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
type HolidayCalendarScope = HolidayCalendarScopeInput;
const HOLIDAY_SCOPE = {
COUNTRY: "COUNTRY",
STATE: "STATE",
CITY: "CITY",
} as const satisfies Record<HolidayCalendarScope, HolidayCalendarScope>;
type HolidayCalendarDb = TRPCContext["db"] & {
holidayCalendar: {
findFirst: (args: unknown) => Promise<{ id: string } | null>;
findMany: (args: unknown) => Promise<any[]>;
findUnique: (args: unknown) => Promise<any | null>;
create: (args: unknown) => Promise<any>;
update: (args: unknown) => Promise<any>;
delete: (args: unknown) => Promise<any>;
};
holidayCalendarEntry: {
findFirst: (args: unknown) => Promise<{ id: string } | null>;
findUnique: (args: unknown) => Promise<any | null>;
create: (args: unknown) => Promise<any>;
update: (args: unknown) => Promise<any>;
delete: (args: unknown) => Promise<any>;
};
};
function asHolidayCalendarDb(db: TRPCContext["db"]): HolidayCalendarDb {
return db as unknown as HolidayCalendarDb;
}
function clampDate(date: Date): Date {
const value = new Date(date);
value.setUTCHours(0, 0, 0, 0);
return value;
}
async function assertEntryDateAvailable(
db: HolidayCalendarDb,
input: {
holidayCalendarId: string;
date: Date;
},
ignoreId?: string,
) {
const existing = await db.holidayCalendarEntry.findFirst({
where: {
holidayCalendarId: input.holidayCalendarId,
date: clampDate(input.date),
...(ignoreId ? { id: { not: ignoreId } } : {}),
},
select: { id: true },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "A holiday entry for this calendar and date already exists",
});
}
}
async function assertScopeConsistency(
db: HolidayCalendarDb,
input: {
scopeType: HolidayCalendarScope;
countryId: string;
stateCode?: string | null;
metroCityId?: string | null;
},
ignoreId?: string,
) {
if (input.scopeType === HOLIDAY_SCOPE.COUNTRY) {
if (input.stateCode || input.metroCityId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Country calendars may not define a state or metro city",
});
}
}
if (input.scopeType === HOLIDAY_SCOPE.STATE) {
if (!input.stateCode) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "State calendars require a state code",
});
}
if (input.metroCityId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "State calendars may not define a metro city",
});
}
}
if (input.scopeType === HOLIDAY_SCOPE.CITY) {
if (!input.metroCityId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "City calendars require a metro city",
});
}
const metroCity = await findUniqueOrThrow(
db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { id: true, countryId: true },
}),
"Metro city",
);
if (metroCity.countryId !== input.countryId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Metro city must belong to the selected country",
});
}
}
const existing = await db.holidayCalendar.findFirst({
where: {
countryId: input.countryId,
scopeType: input.scopeType,
...(input.scopeType === HOLIDAY_SCOPE.STATE ? { stateCode: input.stateCode ?? null } : {}),
...(input.scopeType === HOLIDAY_SCOPE.CITY ? { metroCityId: input.metroCityId ?? null } : {}),
...(ignoreId ? { id: { not: ignoreId } } : {}),
},
select: { id: true },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "A holiday calendar for this exact scope already exists",
});
}
}
export const holidayCalendarRouter = createTRPCRouter({
listCalendars: protectedProcedure
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const where = input?.includeInactive ? undefined : { isActive: true };
return db.holidayCalendar.findMany({
...(where ? { where } : {}),
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
_count: { select: { entries: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
orderBy: [
{ country: { name: "asc" } },
{ scopeType: "asc" },
{ priority: "desc" },
{ name: "asc" },
],
});
}),
getCalendarById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
return findUniqueOrThrow(
db.holidayCalendar.findUnique({
where: { id: input.id },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
}),
"Holiday calendar",
);
}),
createCalendar: adminProcedure
.input(CreateHolidayCalendarSchema)
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { id: true, name: true },
}),
"Country",
);
await assertScopeConsistency(db, {
scopeType: input.scopeType,
countryId: input.countryId,
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
});
const created = await db.holidayCalendar.create({
data: {
name: input.name,
scopeType: input.scopeType,
countryId: input.countryId,
...(input.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
...(input.metroCityId ? { metroCityId: input.metroCityId } : {}),
isActive: input.isActive ?? true,
priority: input.priority ?? 0,
},
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: true,
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendar",
entityId: created.id,
entityName: created.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}),
updateCalendar: adminProcedure
.input(z.object({ id: z.string(), data: UpdateHolidayCalendarSchema }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendar.findUnique({ where: { id: input.id } }),
"Holiday calendar",
);
const stateCode = input.data.stateCode === undefined
? existing.stateCode
: input.data.stateCode?.trim().toUpperCase() ?? null;
const metroCityId = input.data.metroCityId === undefined
? existing.metroCityId
: input.data.metroCityId ?? null;
await assertScopeConsistency(db, {
scopeType: existing.scopeType,
countryId: existing.countryId,
stateCode,
metroCityId,
}, existing.id);
const updated = await db.holidayCalendar.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.stateCode !== undefined ? { stateCode } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.priority !== undefined ? { priority: input.data.priority } : {}),
},
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendar",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
deleteCalendar: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendar.findUnique({
where: { id: input.id },
include: { entries: true },
}),
"Holiday calendar",
);
await db.holidayCalendar.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendar",
entityId: existing.id,
entityName: existing.name,
action: "DELETE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true };
}),
createEntry: adminProcedure
.input(CreateHolidayCalendarEntrySchema)
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
await findUniqueOrThrow(
db.holidayCalendar.findUnique({
where: { id: input.holidayCalendarId },
select: { id: true, name: true },
}),
"Holiday calendar",
);
await assertEntryDateAvailable(db, {
holidayCalendarId: input.holidayCalendarId,
date: input.date,
});
const created = await db.holidayCalendarEntry.create({
data: {
holidayCalendarId: input.holidayCalendarId,
date: clampDate(input.date),
name: input.name,
isRecurringAnnual: input.isRecurringAnnual ?? false,
...(input.source ? { source: input.source } : {}),
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendarEntry",
entityId: created.id,
entityName: created.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}),
updateEntry: adminProcedure
.input(z.object({ id: z.string(), data: UpdateHolidayCalendarEntrySchema }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
"Holiday calendar entry",
);
const nextDate = input.data.date !== undefined ? clampDate(input.data.date) : existing.date;
await assertEntryDateAvailable(db, {
holidayCalendarId: existing.holidayCalendarId,
date: nextDate,
}, existing.id);
const updated = await db.holidayCalendarEntry.update({
where: { id: input.id },
data: {
...(input.data.date !== undefined ? { date: nextDate } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}),
...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}),
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendarEntry",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
deleteEntry: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
"Holiday calendar entry",
);
await db.holidayCalendarEntry.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendarEntry",
entityId: existing.id,
entityName: existing.name,
action: "DELETE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true };
}),
previewResolvedHolidays: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
})
: null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
return resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
}));
}),
});
+2
View File
@@ -15,6 +15,7 @@ import { effortRuleRouter } from "./effort-rule.js";
import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js";
import { holidayCalendarRouter } from "./holiday-calendar.js";
import { importExportRouter } from "./import-export.js";
import { insightsRouter } from "./insights.js";
import { managementLevelRouter } from "./management-level.js";
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
insights: insightsRouter,
vacation: vacationRouter,
entitlement: entitlementRouter,
holidayCalendar: holidayCalendarRouter,
notification: notificationRouter,
settings: settingsRouter,
country: countryRouter,
+45 -7
View File
@@ -2,6 +2,7 @@ import {
countPlanningEntries,
listAssignmentBookings,
} from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
import { invalidateDashboardCache } from "../lib/cache.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
@@ -127,20 +132,53 @@ export const projectRouter = createTRPCRouter({
const assignments = await ctx.db.assignment.findMany({
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
include: { resource: { include: { country: { select: { code: true } } } } },
include: {
resource: {
include: {
country: { select: { id: true, code: true } },
metroCity: { select: { id: true, name: true } },
},
},
},
});
const periodStart = assignments.length > 0
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
: new Date();
const periodEnd = assignments.length > 0
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
: new Date();
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
assignments.map((assignment) => ({
id: assignment.resource.id,
availability: assignment.resource.availability as unknown as WeekdayAvailability,
countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
countryCode: assignment.resource.country?.code,
federalState: assignment.resource.federalState,
metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
metroCityName: assignment.resource.metroCity?.name,
})),
periodStart,
periodEnd,
);
const mapped: ShoringAssignment[] = assignments.map((a) => {
const start = new Date(a.startDate);
const end = new Date(a.endDate);
const diffMs = end.getTime() - start.getTime();
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
const workingDays = Math.round(diffDays / 7 * 5);
const workingDays = a.hoursPerDay > 0
? calculateEffectiveBookedHours({
availability: a.resource.availability as unknown as WeekdayAvailability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart,
periodEnd,
context: contexts.get(a.resourceId ?? a.resource.id),
}) / a.hoursPerDay
: 0;
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
workingDays: Math.max(1, workingDays),
workingDays: Math.max(0, workingDays),
};
});
+637 -69
View File
@@ -1,6 +1,20 @@
import { z } from "zod";
import { Prisma } from "@capakraken/db";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
} from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
// ─── Column Definitions ──────────────────────────────────────────────────────
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
{ key: "clientId", label: "Client ID", dataType: "string" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" },
{ key: "resourceId", label: "Resource ID", dataType: "string" },
{ key: "projectId", label: "Project ID", dataType: "string" },
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "updatedAt", label: "Updated At", dataType: "date" },
];
const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [
{ key: "id", label: "Row ID", dataType: "string" },
{ key: "resourceId", label: "Resource ID", dataType: "string" },
{ key: "monthKey", label: "Month", dataType: "string" },
{ key: "periodStart", label: "Period Start", dataType: "date" },
{ key: "periodEnd", label: "Period End", dataType: "date" },
{ key: "eid", label: "Employee ID", dataType: "string" },
{ key: "displayName", label: "Name", dataType: "string" },
{ key: "email", label: "Email", dataType: "string" },
{ key: "chapter", label: "Chapter", dataType: "string" },
{ key: "resourceType", label: "Resource Type", dataType: "string" },
{ key: "isActive", label: "Active", dataType: "boolean" },
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "countryCode", label: "Country Code", dataType: "string" },
{ key: "countryName", label: "Country", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "metroCityName", label: "Metro City", dataType: "string" },
{ key: "orgUnitName", label: "Org Unit", dataType: "string" },
{ key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" },
{ key: "managementLevelName", label: "Mgmt Level", dataType: "string" },
{ key: "fte", label: "FTE", dataType: "number" },
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
{ key: "currency", label: "Currency", dataType: "string" },
{ key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" },
{ key: "monthlyTargetHours", label: "Target Hours", dataType: "number" },
{ key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" },
{ key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" },
{ key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" },
{ key: "monthlySahHours", label: "SAH", dataType: "number" },
{ key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" },
{ key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" },
{ key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" },
{ key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" },
{ key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" },
{ key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" },
{ key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" },
{ key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" },
{ key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" },
{ key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" },
];
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
resource: RESOURCE_COLUMNS,
project: PROJECT_COLUMNS,
assignment: ASSIGNMENT_COLUMNS,
resource_month: RESOURCE_MONTH_COLUMNS,
};
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
resource: "resource",
project: "project",
assignment: "assignment",
resource_month: "resource_month",
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
"id", "startDate", "endDate", "hoursPerDay", "percentage",
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
]),
resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
};
function getValidScalarField(entity: EntityKey, field: string): string | null {
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
if (!def) continue;
if (colKey.includes(".")) {
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
const fieldName = colKey.split(".").slice(1).join(".");
const existing = select[relationName];
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
} else {
select[relationName] = { select: { [fieldName]: true } };
}
const fieldSegments = colKey.split(".").slice(1);
const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
? (existing as { select: Record<string, unknown> }).select
: {};
mergeSelectPath(relationSelect, fieldSegments);
select[relationName] = { select: relationSelect };
} else {
select[colKey] = true;
}
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
return select;
}
function mergeSelectPath(
target: Record<string, unknown>,
segments: string[],
): void {
const [head, ...tail] = segments;
if (!head) {
return;
}
if (tail.length === 0) {
target[head] = true;
return;
}
const existing = target[head];
const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
? (existing as { select: Record<string, unknown> }).select
: {};
mergeSelectPath(nestedSelect, tail);
target[head] = { select: nestedSelect };
}
/**
* Build a Prisma `where` from the filter array.
* Only scalar top-level fields are allowed for safety.
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
// ─── Input Schema ───────────────────────────────────────────────────────────
const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
const FilterSchema = z.object({
field: z.string().min(1),
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
});
const ReportInputSchema = z.object({
entity: z.enum(["resource", "project", "assignment"]),
entity: reportEntitySchema,
columns: z.array(z.string()).min(1),
filters: z.array(FilterSchema).default([]),
groupBy: z.string().optional(),
sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"),
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0),
});
const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true });
const ReportTemplateEntity = {
RESOURCE: "RESOURCE",
PROJECT: "PROJECT",
ASSIGNMENT: "ASSIGNMENT",
RESOURCE_MONTH: "RESOURCE_MONTH",
} as const;
type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity];
type ReportTemplateRecord = {
id: string;
name: string;
description: string | null;
entity: ReportTemplateEntity;
config: unknown;
isShared: boolean;
ownerId: string;
updatedAt: Date;
};
function getReportTemplateDelegate(db: unknown) {
return (db as {
reportTemplate: {
findMany: (args: unknown) => Promise<ReportTemplateRecord[]>;
findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
delete: (args: unknown) => Promise<unknown>;
};
}).reportTemplate;
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const reportRouter = createTRPCRouter({
listTemplates: controllerProcedure.query(async ({ ctx }) => {
const reportTemplate = getReportTemplateDelegate(ctx.db);
const templates = await reportTemplate.findMany({
where: {
OR: [
{ ownerId: ctx.dbUser!.id },
{ isShared: true },
],
},
orderBy: [{ name: "asc" }],
select: {
id: true,
name: true,
description: true,
entity: true,
config: true,
isShared: true,
ownerId: true,
updatedAt: true,
},
});
return templates.map((template: ReportTemplateRecord) => ({
id: template.id,
name: template.name,
description: template.description,
entity: fromTemplateEntity(template.entity),
config: ReportTemplateConfigSchema.parse(template.config),
isShared: template.isShared,
isOwner: template.ownerId === ctx.dbUser!.id,
updatedAt: template.updatedAt,
}));
}),
saveTemplate: controllerProcedure
.input(z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).optional(),
isShared: z.boolean().default(false),
config: ReportTemplateConfigSchema,
}))
.mutation(async ({ ctx, input }) => {
const reportTemplate = getReportTemplateDelegate(ctx.db);
const payload = input.config as unknown as Prisma.InputJsonValue;
const entity = toTemplateEntity(input.config.entity);
if (input.id) {
const existing = await reportTemplate.findUnique({
where: { id: input.id },
select: { ownerId: true },
});
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
}
return reportTemplate.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
select: { id: true, updatedAt: true },
});
}
return reportTemplate.upsert({
where: {
ownerId_name: {
ownerId: ctx.dbUser!.id,
name: input.name,
},
},
update: {
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
create: {
ownerId: ctx.dbUser!.id,
name: input.name,
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
select: { id: true, updatedAt: true },
});
}),
deleteTemplate: controllerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const reportTemplate = getReportTemplateDelegate(ctx.db);
const existing = await reportTemplate.findUnique({
where: { id: input.id },
select: { ownerId: true },
});
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
}
await reportTemplate.delete({ where: { id: input.id } });
return { ok: true };
}),
/**
* Return available columns for a given entity type.
*/
getAvailableColumns: controllerProcedure
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
.input(z.object({ entity: reportEntitySchema }))
.query(({ input }) => {
const columns = COLUMN_MAP[input.entity];
if (!columns) {
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
getReportData: controllerProcedure
.input(ReportInputSchema)
.query(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
// Build orderBy (only scalar fields)
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const [rawRows, totalCount] = await Promise.all([
(modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
skip: offset,
}),
(modelDelegate as any).count({ where }),
]);
// Flatten nested relations into dot-notation keys
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
// Ensure column order matches request (plus id)
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
return { rows, columns: outputColumns, totalCount };
return executeReportQuery(ctx.db, input);
}),
/**
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
limit: z.number().int().min(1).max(50000).default(5000),
}))
.mutation(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const rawRows = await (modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
});
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
const rows = result.rows;
const outputColumns = result.columns;
// Build CSV
const entityColumns = COLUMN_MAP[entity];
const entityColumns = COLUMN_MAP[input.entity];
const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((c) => c.key === key);
return def?.label ?? key;
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
}),
});
type ReportInput = z.infer<typeof ReportInputSchema>;
type FilterInput = z.infer<typeof FilterSchema>;
async function executeReportQuery(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
}
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(db, entity);
const [rawRows, totalCount] = await Promise.all([
(modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
skip: offset,
}),
(modelDelegate as any).count({ where }),
]);
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
return {
rows: rows.map((row) => pickColumns(row, outputColumns)),
columns: outputColumns,
totalCount,
};
}
async function executeResourceMonthReport(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7);
const [year, month] = periodMonth.split("-").map(Number) as [number, number];
const periodStart = new Date(Date.UTC(year, month - 1, 1));
const periodEnd = new Date(Date.UTC(year, month, 0));
const resources = await db.resource.findMany({
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
resourceType: true,
isActive: true,
chgResponsibility: true,
rolledOff: true,
departed: true,
lcrCents: true,
ucrCents: true,
currency: true,
fte: true,
availability: true,
chargeabilityTarget: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
orgUnit: { select: { name: true } },
managementLevelGroup: { select: { name: true, targetPercentage: true } },
managementLevel: { select: { name: true } },
},
orderBy: { displayName: "asc" },
});
const resourceIds = resources.map((resource: any) => resource.id);
const [bookings, contexts] = await Promise.all([
resourceIds.length > 0
? listAssignmentBookings(db, {
startDate: periodStart,
endDate: periodEnd,
resourceIds,
})
: Promise.resolve([]),
loadResourceDailyAvailabilityContexts(
db,
resources.map((resource: any) => ({
id: resource.id,
availability: resource.availability as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
periodStart,
periodEnd,
),
]);
const rows = resources.map((resource: any) => {
const availability = resource.availability as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart,
periodEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability,
periodStart,
periodEnd,
context,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context: undefined,
});
const sahHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context,
});
const holidayDates = [...(context?.holidayDates ?? new Set<string>())];
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const actualBookedHours = resourceBookings
.filter((booking) => isChargeabilityActualBooking(booking, false))
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}), 0);
const expectedBookedHours = resourceBookings
.filter((booking) => isChargeabilityRelevantProject(booking.project, true))
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}), 0);
const targetPct = resource.managementLevelGroup?.targetPercentage != null
? resource.managementLevelGroup.targetPercentage * 100
: resource.chargeabilityTarget;
return {
id: `${resource.id}:${periodMonth}`,
resourceId: resource.id,
monthKey: periodMonth,
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter,
resourceType: resource.resourceType,
isActive: resource.isActive,
chgResponsibility: resource.chgResponsibility,
rolledOff: resource.rolledOff,
departed: resource.departed,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name ?? null,
orgUnitName: resource.orgUnit?.name ?? null,
managementLevelGroupName: resource.managementLevelGroup?.name ?? null,
managementLevelName: resource.managementLevel?.name ?? null,
fte: roundMetric(resource.fte),
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
monthlyChargeabilityTargetPct: roundMetric(targetPct),
monthlyTargetHours: roundMetric((sahHours * targetPct) / 100),
monthlyBaseWorkingDays: roundMetric(baseWorkingDays),
monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays),
monthlyBaseAvailableHours: roundMetric(baseAvailableHours),
monthlySahHours: roundMetric(sahHours),
monthlyPublicHolidayCount: holidayDates.length,
monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount,
monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction),
monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent),
monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction),
monthlyActualBookedHours: roundMetric(actualBookedHours),
monthlyExpectedBookedHours: roundMetric(expectedBookedHours),
monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0),
monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0),
monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)),
};
});
const filteredRows = rows.filter((row: Record<string, unknown>) => input.filters.every((filter) => matchesInMemoryFilter(
row,
filter,
RESOURCE_MONTH_COLUMNS,
)));
const sortedRows = sortInMemoryRows(filteredRows, input.sortBy, input.sortDir, RESOURCE_MONTH_COLUMNS);
const totalCount = sortedRows.length;
const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit);
const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")];
return {
rows: pagedRows.map((row) => pickColumns(row, outputColumns)),
columns: outputColumns,
totalCount,
};
}
function parseFilterValue(def: ColumnDef | undefined, value: string): unknown {
if (!def) return value;
if (def.dataType === "number") {
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
if (def.dataType === "boolean") {
return value === "true";
}
if (def.dataType === "date") {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
}
return value;
}
function matchesInMemoryFilter(
row: Record<string, unknown>,
filter: FilterInput,
columns: ColumnDef[],
): boolean {
const def = columns.find((column) => column.key === filter.field);
if (!def) {
return true;
}
const rowValueRaw = row[filter.field];
const rowValue = def.dataType === "date" && typeof rowValueRaw === "string"
? new Date(rowValueRaw).getTime()
: rowValueRaw;
const parsedFilterValue = parseFilterValue(def, filter.value);
if (parsedFilterValue === null) {
return false;
}
switch (filter.op) {
case "eq":
return rowValue === parsedFilterValue;
case "neq":
return rowValue !== parsedFilterValue;
case "gt":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue;
case "lt":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue;
case "gte":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue;
case "lte":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue;
case "contains":
return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase());
case "in":
return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? ""));
default:
return true;
}
}
function sortInMemoryRows(
rows: Record<string, unknown>[],
sortBy: string | undefined,
sortDir: "asc" | "desc",
columns: ColumnDef[],
): Record<string, unknown>[] {
if (!sortBy) {
return rows;
}
const def = columns.find((column) => column.key === sortBy);
if (!def) {
return rows;
}
const direction = sortDir === "asc" ? 1 : -1;
return [...rows].sort((left, right) => {
const leftValue = left[sortBy];
const rightValue = right[sortBy];
if (leftValue == null && rightValue == null) return 0;
if (leftValue == null) return 1;
if (rightValue == null) return -1;
if (def.dataType === "number") {
return direction * (Number(leftValue) - Number(rightValue));
}
if (def.dataType === "boolean") {
return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue)));
}
if (def.dataType === "date") {
return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime());
}
return direction * String(leftValue).localeCompare(String(rightValue), "de");
});
}
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
return Object.fromEntries(columns.map((column) => [column, row[column]]));
}
function roundMetric(value: number): number {
return Math.round(value * 10) / 10;
}
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
switch (entity) {
case "resource":
return ReportTemplateEntity.RESOURCE;
case "project":
return ReportTemplateEntity.PROJECT;
case "assignment":
return ReportTemplateEntity.ASSIGNMENT;
case "resource_month":
return ReportTemplateEntity.RESOURCE_MONTH;
default:
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
}
}
function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey {
switch (entity) {
case ReportTemplateEntity.RESOURCE:
return "resource";
case ReportTemplateEntity.PROJECT:
return "project";
case ReportTemplateEntity.ASSIGNMENT:
return "assignment";
case ReportTemplateEntity.RESOURCE_MONTH:
return "resource_month";
default:
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
}
}
/** Resolve the Prisma model delegate from entity key. */
function getModelDelegate(db: any, entity: EntityKey) {
switch (entity) {
+239 -50
View File
@@ -7,7 +7,6 @@ import {
} from "@capakraken/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { computeChargeability } from "@capakraken/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
@@ -17,6 +16,12 @@ import {
getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
@@ -46,6 +51,50 @@ function parseResourceCursor(cursor: string | undefined): { displayName: string;
return null;
}
type BookingForCapacity = {
startDate: Date;
endDate: Date;
hoursPerDay: number;
};
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function buildDailyBookedHoursMap(
bookings: BookingForCapacity[],
availability: WeekdayAvailability,
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
periodStart: Date,
periodEnd: Date,
): Map<string, number> {
const dailyBookedHours = new Map<string, number>();
const cursor = new Date(periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
const bookedHours = bookings.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: cursor,
periodEnd: cursor,
context,
}),
0,
);
dailyBookedHours.set(isoDate, bookedHours);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return dailyBookedHours;
}
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
portfolioUrl: true,
postalCode: true,
federalState: true,
countryId: true,
metroCityId: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const bookingsByResourceId = new Map<string, typeof bookings>();
for (const booking of bookings) {
if (!booking.resourceId) {
continue;
}
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
items.push(booking);
bookingsByResourceId.set(booking.resourceId, items);
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
start,
end,
);
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as Record<string, number>;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
const periodDays =
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
const availableHours = dailyAvailHours * periodDays * (5 / 7);
let bookedHours = 0;
let isOverbooked = false;
const resourceBookings = bookings.filter(
const availability = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
(booking) =>
booking.resourceId === r.id &&
(input.includeProposed || booking.status !== "PROPOSED"),
);
for (const a of resourceBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
bookedHours += a.hoursPerDay * days;
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
}
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: start,
periodEnd: end,
context,
});
const bookedHours = resourceBookings.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
const date = new Date(`${isoDate}T00:00:00.000Z`);
const dayCapacity = calculateEffectiveDayAvailability({
availability,
date,
context,
});
return dayCapacity > 0 && hours > dayCapacity;
});
const utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
chapter: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
start,
end,
);
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
const actualAllocs = resourceBookings.filter((booking) =>
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
const availableHours = calculateEffectiveAvailableHours({
availability: avail,
periodStart: start,
periodEnd: end,
context,
});
const actualBookedHours = actualAllocs.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability: avail,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const expectedBookedHours = expectedAllocs.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability: avail,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const actualChargeability = availableHours > 0
? Math.round((actualBookedHours / availableHours) * 100)
: 0;
const expectedChargeability = availableHours > 0
? Math.round((expectedBookedHours / availableHours) * 100)
: 0;
return anonymizeResource({
id: r.id,
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
displayName: r.displayName,
chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.availableHours,
actualChargeability,
expectedChargeability,
availableHours: Math.round(availableHours),
}, directory);
});
}),
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const now = new Date();
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const today = new Date(now);
today.setUTCHours(0, 0, 0, 0);
const thirtyDaysFromNow = new Date(today);
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
skills: true,
availability: true,
chargeabilityTarget: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
@@ -1232,7 +1384,7 @@ export const resourceRouter = createTRPCRouter({
where: {
resourceId: { in: allResourceIds },
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
endDate: { gte: now },
endDate: { gte: today },
startDate: { lte: thirtyDaysFromNow },
},
select: {
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
hoursPerDay: true,
},
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
today,
thirtyDaysFromNow,
);
const assignmentsByResourceId = new Map<string, typeof assignments>();
for (const assignment of assignments) {
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
items.push(assignment);
assignmentsByResourceId.set(assignment.resourceId, items);
}
// Build utilization map (simple: booked hours per day / available hours per day)
// Build utilization map with holiday-aware daily capacity over the next 30 days.
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);
const availability = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
const todayAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: today,
periodEnd: today,
context,
});
const todayBookedHours = resourceAssignments.reduce(
(sum, assignment) => sum + calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: today,
periodEnd: today,
context,
}),
0,
);
const utilizationPercent = todayAvailableHours > 0
? Math.round((todayBookedHours / todayAvailableHours) * 100)
: 0;
const dailyBookedHours = buildDailyBookedHoursMap(
resourceAssignments,
availability,
context,
today,
thirtyDaysFromNow,
);
// 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);
const checkDate = new Date(today);
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) {
const dayAvailableHours = calculateEffectiveDayAvailability({
availability,
date: checkDate,
context,
});
if (dayAvailableHours > 0) {
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
if (dayBookedHours < dayAvailableHours * 0.8) {
earliestAvailableDate = new Date(checkDate);
break;
}
}
checkDate.setDate(checkDate.getDate() + 1);
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
}
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
+174 -66
View File
@@ -1,8 +1,14 @@
import { calculateAllocation, countWorkingDays } from "@capakraken/engine/allocation";
import { calculateAllocation } from "@capakraken/engine/allocation";
import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, controllerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
const DEFAULT_AVAILABILITY = {
monday: 8,
@@ -69,6 +75,11 @@ export const scenarioRouter = createTRPCRouter({
availability: true,
chargeabilityTarget: true,
skills: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
},
roleEntity: { select: { id: true, name: true, color: true } },
@@ -85,23 +96,53 @@ export const scenarioRouter = createTRPCRouter({
},
});
// Calculate baseline totals
let totalCostCents = 0;
let totalHours = 0;
const assignmentRangeStart = assignments.length > 0
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
: project.startDate;
const assignmentRangeEnd = assignments.length > 0
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
: project.endDate;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
assignments
.flatMap((assignment) => (assignment.resource ? [assignment.resource] : []))
.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
assignmentRangeStart,
assignmentRangeEnd,
);
const baselineAllocations = assignments.map((a) => {
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const lcrCents = a.resource?.lcrCents ?? 0;
const result = calculateAllocation({
lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
totalCostCents += result.totalCostCents;
totalHours += result.totalHours;
const totalHours = a.resourceId
? calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: assignmentRangeStart,
periodEnd: assignmentRangeEnd,
context: contexts.get(a.resourceId),
})
: calculateAllocation({
lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
}).totalHours;
const costCents = Math.round(totalHours * lcrCents);
const workingDays = a.hoursPerDay > 0
? Math.round((totalHours / a.hoursPerDay) * 100) / 100
: 0;
return {
id: a.id,
@@ -116,11 +157,13 @@ export const scenarioRouter = createTRPCRouter({
endDate: a.endDate.toISOString(),
hoursPerDay: a.hoursPerDay,
status: a.status,
costCents: result.totalCostCents,
totalHours: result.totalHours,
workingDays: result.workingDays,
costCents,
totalHours,
workingDays,
};
});
const totalCostCents = baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0);
const totalHours = baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0);
const baselineDemands = demands.map((d) => ({
id: d.id,
@@ -175,27 +218,16 @@ export const scenarioRouter = createTRPCRouter({
availability: true,
chargeabilityTarget: true,
skills: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
},
},
});
// Compute baseline totals
let baselineCostCents = 0;
let baselineHours = 0;
for (const a of currentAssignments) {
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const result = calculateAllocation({
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
});
baselineCostCents += result.totalCostCents;
baselineHours += result.totalHours;
}
// Collect all resource IDs we need to look up (from changes)
const resourceIds = new Set<string>();
for (const c of changes) {
@@ -217,6 +249,11 @@ export const scenarioRouter = createTRPCRouter({
availability: true,
chargeabilityTarget: true,
skills: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const resourceMap = new Map(resources.map((r) => [r.id, r]));
@@ -287,22 +324,6 @@ export const scenarioRouter = createTRPCRouter({
});
}
// Compute scenario totals
let scenarioCostCents = 0;
let scenarioHours = 0;
for (const entry of scenarioEntries) {
const result = calculateAllocation({
lcrCents: entry.lcrCents,
hoursPerDay: entry.hoursPerDay,
startDate: entry.startDate,
endDate: entry.endDate,
availability: entry.availability,
});
scenarioCostCents += result.totalCostCents;
scenarioHours += result.totalHours;
}
// Compute per-resource utilization impact
// Load ALL assignments for affected resources (across all projects) to measure total utilization
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
@@ -341,13 +362,97 @@ export const scenarioRouter = createTRPCRouter({
if (e.endDate > windowEnd) windowEnd = e.endDate;
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
windowStart,
windowEnd,
);
function calculateEntryHours(entry: {
resourceId: string | null;
lcrCents: number;
hoursPerDay: number;
startDate: Date;
endDate: Date;
availability: typeof DEFAULT_AVAILABILITY;
}) {
if (!entry.resourceId) {
return calculateAllocation({
lcrCents: entry.lcrCents,
hoursPerDay: entry.hoursPerDay,
startDate: entry.startDate,
endDate: entry.endDate,
availability: entry.availability,
}).totalHours;
}
return calculateEffectiveBookedHours({
availability: entry.availability,
startDate: entry.startDate,
endDate: entry.endDate,
hoursPerDay: entry.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context: contexts.get(entry.resourceId),
});
}
// Compute scenario totals
let scenarioCostCents = 0;
let scenarioHours = 0;
for (const entry of scenarioEntries) {
const totalHours = calculateEntryHours(entry);
scenarioCostCents += Math.round(totalHours * entry.lcrCents);
scenarioHours += totalHours;
}
let baselineCostCents = 0;
let baselineHours = 0;
for (const assignment of currentAssignments) {
const availability = (assignment.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const totalHours = assignment.resourceId
? calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context: contexts.get(assignment.resourceId),
})
: calculateAllocation({
lcrCents: assignment.resource?.lcrCents ?? 0,
hoursPerDay: assignment.hoursPerDay,
startDate: assignment.startDate,
endDate: assignment.endDate,
availability,
}).totalHours;
baselineHours += totalHours;
baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0));
}
const resourceImpacts = affectedResourceIds.map((resId) => {
const resource = resourceMap.get(resId);
if (!resource) return null;
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const totalWorkDays = countWorkingDays(windowStart, windowEnd, availability);
const totalAvailableHours = totalWorkDays * (availability.monday ?? 8);
const context = contexts.get(resId);
const totalAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
// Current utilization on this project
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
@@ -355,28 +460,30 @@ export const scenarioRouter = createTRPCRouter({
);
let currentProjectHours = 0;
for (const a of currentProjectAssignments) {
const r = calculateAllocation({
lcrCents: 0,
hoursPerDay: a.hoursPerDay,
currentProjectHours += calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
availability,
hoursPerDay: a.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
currentProjectHours += r.totalHours;
}
// Scenario hours for this resource on this project
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
let scenarioProjectHours = 0;
for (const e of scenarioResourceEntries) {
const r = calculateAllocation({
lcrCents: 0,
hoursPerDay: e.hoursPerDay,
scenarioProjectHours += calculateEffectiveBookedHours({
availability,
startDate: e.startDate,
endDate: e.endDate,
availability,
hoursPerDay: e.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
scenarioProjectHours += r.totalHours;
}
// Total hours across all projects (excluding this project's current, adding scenario)
@@ -385,14 +492,15 @@ export const scenarioRouter = createTRPCRouter({
);
let otherProjectsHours = 0;
for (const a of otherProjectAssignments) {
const r = calculateAllocation({
lcrCents: 0,
hoursPerDay: a.hoursPerDay,
otherProjectsHours += calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
availability,
hoursPerDay: a.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
otherProjectsHours += r.totalHours;
}
const currentTotalHours = otherProjectsHours + currentProjectHours;
+537 -43
View File
@@ -1,10 +1,106 @@
import { analyzeUtilization, findCapacityWindows, rankResources } from "@capakraken/staffing";
import { rankResources } from "@capakraken/staffing";
import { listAssignmentBookings } from "@capakraken/application";
import { TRPCError } from "@trpc/server";
import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
type ResourceDailyAvailabilityContext,
} from "../lib/resource-capacity.js";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function getBaseDayAvailability(
availability: WeekdayAvailability,
date: Date,
): number {
const key = DAY_KEYS[date.getUTCDay()];
return key ? (availability[key] ?? 0) : 0;
}
function getEffectiveDayAvailability(
availability: WeekdayAvailability,
date: Date,
context: ResourceDailyAvailabilityContext | undefined,
): number {
const key = DAY_KEYS[date.getUTCDay()];
const baseHours = key ? (availability[key] ?? 0) : 0;
if (baseHours <= 0) {
return 0;
}
const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0;
return Math.max(0, baseHours * (1 - fraction));
}
function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean {
return date >= startDate && date <= endDate;
}
function averagePerWorkingDay(totalHours: number, workingDays: number): number {
if (workingDays <= 0) {
return 0;
}
return round1(totalHours / workingDays);
}
function createLocationLabel(input: {
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}): string {
return [
input.countryCode ?? null,
input.federalState ?? null,
input.metroCityName ?? null,
].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / ");
}
function calculateAllocatedHoursForDay(input: {
bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>;
date: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): { allocatedHours: number; chargeableHours: number } {
const isoDate = toIsoDate(input.date);
const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0));
return input.bookings.reduce(
(acc, booking) => {
if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) {
return acc;
}
const effectiveHours = booking.hoursPerDay * dayFraction;
acc.allocatedHours += effectiveHours;
if (booking.isChargeable) {
acc.chargeableHours += effectiveHours;
}
return acc;
},
{ allocatedHours: 0, chargeableHours: 0 },
);
}
export const staffingRouter = createTRPCRouter({
/**
* Get ranked resource suggestions for a staffing requirement.
@@ -32,31 +128,169 @@ export const staffingRouter = createTRPCRouter({
isActive: true,
...(chapter ? { chapter } : {}),
},
select: {
id: true,
displayName: true,
eid: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
availability: true,
valueScore: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate,
endDate,
resourceIds: resources.map((resource) => resource.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
startDate,
endDate,
);
const bookingsByResourceId = new Map<string, typeof bookings>();
for (const booking of bookings) {
if (!booking.resourceId) {
continue;
}
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
items.push(booking);
bookingsByResourceId.set(booking.resourceId, items);
}
// Compute utilization percent for each resource in the requested period
const enrichedResources = resources.map((resource) => {
const totalAvailableHours =
(resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, a) => sum + a.hoursPerDay,
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: startDate,
periodEnd: endDate,
context: undefined,
});
const totalAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: startDate,
periodEnd: endDate,
context,
});
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: startDate,
periodEnd: endDate,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: startDate,
periodEnd: endDate,
context,
});
const allocatedHours = activeBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: startDate,
periodEnd: endDate,
context,
}),
0,
);
const holidayDates = [...(context?.holidayDates ?? new Set<string>())].sort();
const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const conflictDays: string[] = [];
const conflictDetails: Array<{
date: string;
baseHours: number;
effectiveHours: number;
allocatedHours: number;
remainingHours: number;
requestedHours: number;
shortageHours: number;
absenceFraction: number;
isHoliday: boolean;
}> = [];
const cursor = new Date(startDate);
cursor.setUTCHours(0, 0, 0, 0);
const periodEndAtMidnight = new Date(endDate);
periodEndAtMidnight.setUTCHours(0, 0, 0, 0);
while (cursor <= periodEndAtMidnight) {
const isoDate = toIsoDate(cursor);
const baseHoursForDay = getBaseDayAvailability(availability, cursor);
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
const absenceFraction = Math.min(
1,
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
);
if (availableHoursForDay > 0) {
const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({
bookings: activeBookings,
date: cursor,
context,
});
if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) {
const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay);
conflictDays.push(isoDate);
conflictDetails.push({
date: isoDate,
baseHours: round1(baseHoursForDay),
effectiveHours: round1(availableHoursForDay),
allocatedHours: round1(allocatedHoursForDay),
remainingHours: round1(remainingHoursForDay),
requestedHours: round1(hoursPerDay),
shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)),
absenceFraction: round1(absenceFraction),
isHoliday,
});
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const remainingHours = Math.max(0, totalAvailableHours - allocatedHours);
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours;
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
let skills = resource.skills as unknown as SkillRow[];
@@ -73,9 +307,43 @@ export const staffingRouter = createTRPCRouter({
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
hasAvailabilityConflicts: conflictDays.length > 0,
conflictDays,
valueScore: resource.valueScore ?? 0,
transparency: {
location: {
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
label: createLocationLabel({
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name ?? null,
}),
},
capacity: {
requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay),
baseWorkingDays: round1(baseWorkingDays),
effectiveWorkingDays: round1(effectiveWorkingDays),
baseAvailableHours: round1(baseAvailableHours),
effectiveAvailableHours: round1(totalAvailableHours),
bookedHours: round1(allocatedHours),
remainingHours: round1(remainingHours),
remainingHoursPerDay,
holidayCount: holidayDates.length,
holidayWorkdayCount,
holidayHoursDeduction: round1(holidayHoursDeduction),
absenceDayEquivalent: round1(absenceDayEquivalent),
absenceHoursDeduction: round1(absenceHoursDeduction),
},
conflicts: {
count: conflictDays.length,
conflictDays,
details: conflictDetails,
},
},
};
});
@@ -85,15 +353,95 @@ export const staffingRouter = createTRPCRouter({
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
// Value-score tiebreaker: within 2 points, prefer higher valueScore
return ranked.sort((a, b) => {
return [...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;
}).map((suggestion, index) => {
const resource = enrichedResources.find((item) => item.id === suggestion.resourceId);
const fallbackBreakdown = "breakdown" in suggestion
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown
: undefined;
const scoreBreakdown = suggestion.scoreBreakdown ?? {
skillScore: fallbackBreakdown?.skillScore ?? 0,
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
costScore: fallbackBreakdown?.costScore ?? 0,
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
total: suggestion.score,
};
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
const tieBreakerApplied = baseRank !== index + 1;
return {
...suggestion,
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
eid: suggestion.eid ?? resource?.eid ?? "",
scoreBreakdown,
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) =>
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
),
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
),
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8),
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
valueScore: resource?.valueScore ?? 0,
location: resource?.transparency.location ?? {
countryCode: null,
countryName: null,
federalState: null,
metroCityName: null,
label: "",
},
capacity: resource?.transparency.capacity ?? {
requestedHoursPerDay: round1(hoursPerDay),
requestedHoursTotal: 0,
baseWorkingDays: 0,
effectiveWorkingDays: 0,
baseAvailableHours: 0,
effectiveAvailableHours: 0,
bookedHours: 0,
remainingHours: 0,
remainingHoursPerDay: 0,
holidayCount: 0,
holidayWorkdayCount: 0,
holidayHoursDeduction: 0,
absenceDayEquivalent: 0,
absenceHoursDeduction: 0,
},
conflicts: resource?.transparency.conflicts ?? {
count: 0,
conflictDays: [],
details: [],
},
ranking: {
rank: index + 1,
baseRank,
tieBreakerApplied,
tieBreakerReason: tieBreakerApplied
? "Within 2 score points, higher value score moves the candidate up."
: null,
model: "Composite ranking across skill fit, availability, cost, and utilization.",
components: [
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore },
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore },
],
},
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
};
});
}),
@@ -117,6 +465,11 @@ export const staffingRouter = createTRPCRouter({
displayName: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
@@ -128,24 +481,83 @@ export const staffingRouter = createTRPCRouter({
resourceIds: [resource.id],
});
return analyzeUtilization({
resource: {
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
displayName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
},
allocations: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
})) as unknown as Parameters<typeof analyzeUtilization>[0]["allocations"],
analysisStart: input.startDate,
analysisEnd: input.endDate,
});
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
const activeBookings = resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
projectName: booking.project.name,
isChargeable: booking.project.orderType === "CHARGEABLE",
}));
const overallocatedDays: string[] = [];
const underutilizedDays: string[] = [];
let totalAvailableHours = 0;
let totalChargeableHours = 0;
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
if (availableHoursForDay > 0) {
const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({
bookings: activeBookings,
date: cursor,
context,
});
totalAvailableHours += availableHoursForDay;
totalChargeableHours += chargeableHours;
if (allocatedHours > availableHoursForDay) {
overallocatedDays.push(toIsoDate(cursor));
} else if (allocatedHours < availableHoursForDay * 0.5) {
underutilizedDays.push(toIsoDate(cursor));
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
const currentChargeability = totalAvailableHours > 0
? (totalChargeableHours / totalAvailableHours) * 100
: 0;
return {
resourceId: resource.id,
resourceName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
chargeabilityGap: resource.chargeabilityTarget - currentChargeability,
allocations: activeBookings
.filter((booking) => ACTIVE_STATUSES.has(booking.status))
.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
projectName: booking.projectName,
isChargeable: booking.isChargeable,
})),
overallocatedDays,
underutilizedDays,
};
}),
/**
@@ -168,6 +580,11 @@ export const staffingRouter = createTRPCRouter({
id: true,
displayName: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
"Resource",
@@ -179,21 +596,98 @@ export const staffingRouter = createTRPCRouter({
resourceIds: [resource.id],
});
return findCapacityWindows(
{
const availability = resource.availability as unknown as WeekdayAvailability;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
displayName: resource.displayName,
availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
},
resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<import("@capakraken/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
input.minAvailableHoursPerDay,
);
const context = contexts.get(resource.id);
const windows: Array<{
resourceId: string;
resourceName: string;
startDate: Date;
endDate: Date;
availableHoursPerDay: number;
availableDays: number;
totalAvailableHours: number;
}> = [];
let windowStart: Date | null = null;
let windowAvailableDays = 0;
let windowTotalHours = 0;
let windowMinHours = Number.POSITIVE_INFINITY;
const closeWindow = (closeDate: Date) => {
if (windowStart && windowAvailableDays > 0) {
const previousDay = new Date(closeDate);
previousDay.setUTCDate(previousDay.getUTCDate() - 1);
windows.push({
resourceId: resource.id,
resourceName: resource.displayName,
startDate: new Date(windowStart),
endDate: previousDay,
availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0,
availableDays: windowAvailableDays,
totalAvailableHours: Math.round(windowTotalHours * 10) / 10,
});
}
windowStart = null;
windowAvailableDays = 0;
windowTotalHours = 0;
windowMinHours = Number.POSITIVE_INFINITY;
};
const cursor = new Date(input.startDate);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.endDate);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
if (availableHoursForDay <= 0) {
closeWindow(cursor);
cursor.setUTCDate(cursor.getUTCDate() + 1);
continue;
}
const { allocatedHours } = calculateAllocatedHoursForDay({
bookings: resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})),
date: cursor,
context,
});
const freeHours = Math.max(0, availableHoursForDay - allocatedHours);
if (freeHours >= input.minAvailableHoursPerDay) {
if (!windowStart) {
windowStart = new Date(cursor);
}
windowAvailableDays += 1;
windowTotalHours += freeHours;
windowMinHours = Math.min(windowMinHours, freeHours);
} else {
closeWindow(cursor);
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
closeWindow(new Date(end.getTime() + 86_400_000));
return windows;
}),
});
+112 -1
View File
@@ -26,6 +26,7 @@ import {
emitAllocationUpdated,
emitProjectShifted,
} from "../sse/event-bus.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
@@ -37,7 +38,7 @@ type ShiftDbClient = Pick<
type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment" | "resource" | "project"
"demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
>;
type TimelineEntriesFilters = {
@@ -325,6 +326,116 @@ export const timelineRouter = createTRPCRouter({
};
}),
getHolidayOverlays: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
clientIds: z.array(z.string()).optional(),
chapters: z.array(z.string()).optional(),
eids: z.array(z.string()).optional(),
countryCodes: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
const resourceIds = [...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
)];
if (input.resourceIds && input.resourceIds.length > 0) {
for (const resourceId of input.resourceIds) {
if (resourceId && !resourceIds.includes(resourceId)) {
resourceIds.push(resourceId);
}
}
}
const hasResourceFilters =
(input.chapters?.length ?? 0) > 0 ||
(input.eids?.length ?? 0) > 0 ||
(input.countryCodes?.length ?? 0) > 0;
if (hasResourceFilters) {
const andConditions: Record<string, unknown>[] = [];
if (input.chapters && input.chapters.length > 0) {
andConditions.push({ chapter: { in: input.chapters } });
}
if (input.eids && input.eids.length > 0) {
andConditions.push({ eid: { in: input.eids } });
}
if (input.countryCodes && input.countryCodes.length > 0) {
andConditions.push({ country: { code: { in: input.countryCodes } } });
}
const matchingResources = await ctx.db.resource.findMany({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
select: { id: true },
});
for (const resource of matchingResources) {
if (!resourceIds.includes(resource.id)) {
resourceIds.push(resource.id);
}
}
}
if (resourceIds.length === 0) {
return [];
}
const resources = await ctx.db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const overlays = await Promise.all(
resources.map(async (resource) => {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: input.startDate,
periodEnd: input.endDate,
countryId: resource.countryId,
countryCode: resource.country?.code ?? null,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name ?? null,
});
return holidays.map((holiday) => {
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
return {
id: `calendar-holiday:${resource.id}:${holiday.date}`,
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: "APPROVED" as const,
startDate: holidayDate,
endDate: holidayDate,
note: holiday.name,
};
});
}),
);
return overlays.flat().sort((left, right) => {
if (left.resourceId !== right.resourceId) {
return left.resourceId.localeCompare(right.resourceId);
}
return left.startDate.getTime() - right.startDate.getTime();
});
}),
/**
* Get full project context for a project:
* - project with staffingReqs and budget
+200 -25
View File
@@ -1,4 +1,4 @@
import { UpdateVacationStatusSchema, getPublicHolidays, buildTaskAction } from "@capakraken/shared";
import { UpdateVacationStatusSchema, buildTaskAction } from "@capakraken/shared";
import { VacationStatus, VacationType } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -12,9 +12,82 @@ import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
function isSameUtcDay(left: Date, right: Date): boolean {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
const PreviewVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
isHalfDay: z.boolean().optional(),
}).superRefine((data, ctx) => {
if (data.endDate < data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date must be after start date",
path: ["endDate"],
});
}
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests must start and end on the same day",
path: ["isHalfDay"],
});
}
});
const CreateVacationRequestSchema = z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
note: z.string().max(500).optional(),
isHalfDay: z.boolean().optional(),
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
}).superRefine((data, ctx) => {
if (data.endDate < data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date must be after start date",
path: ["endDate"],
});
}
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests must start and end on the same day",
path: ["isHalfDay"],
});
}
if (data.isHalfDay && !data.halfDayPart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day requests require a half-day part",
path: ["halfDayPart"],
});
}
if (!data.isHalfDay && data.halfDayPart) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Half-day part is only allowed for half-day requests",
path: ["halfDayPart"],
});
}
});
function anonymizeVacationRecord<T extends {
resource?: { id: string } | null;
@@ -78,6 +151,64 @@ async function notifyVacationStatus(
}
export const vacationRouter = createTRPCRouter({
previewRequest: protectedProcedure
.input(PreviewVacationRequestSchema)
.query(async ({ ctx, input }) => {
const holidayContext = await loadResourceHolidayContext(
ctx.db,
input.resourceId,
input.startDate,
input.endDate,
);
const vacation = {
startDate: input.startDate,
endDate: input.endDate,
isHalfDay: input.isHalfDay ?? false,
};
const requestedDays = countCalendarDaysInPeriod(vacation);
const effectiveDays = BALANCE_TYPES.has(input.type)
? countVacationChargeableDays({
vacation,
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
})
: requestedDays;
const publicHolidayDates = [...new Set([
...holidayContext.calendarHolidayStrings,
...holidayContext.publicHolidayStrings,
])].sort();
const holidayDetails = publicHolidayDates.map((date) => ({
date,
source:
holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date)
? "CALENDAR_AND_LEGACY"
: holidayContext.calendarHolidayStrings.includes(date)
? "CALENDAR"
: "LEGACY_PUBLIC_HOLIDAY",
}));
return {
requestedDays,
effectiveDays,
deductedDays: BALANCE_TYPES.has(input.type) ? effectiveDays : 0,
publicHolidayDates,
holidayDetails,
holidayContext: {
countryCode: holidayContext.countryCode ?? null,
countryName: holidayContext.countryName ?? null,
federalState: holidayContext.federalState ?? null,
metroCityName: holidayContext.metroCityName ?? null,
sources: {
hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0,
hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0,
},
},
};
}),
/**
* List vacations with optional filters.
*/
@@ -141,21 +272,15 @@ export const vacationRouter = createTRPCRouter({
* Adds isHalfDay + halfDayPart support.
*/
create: protectedProcedure
.input(
z.object({
resourceId: z.string(),
type: z.nativeEnum(VacationType),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
note: z.string().max(500).optional(),
isHalfDay: z.boolean().optional(),
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
}).refine((d) => d.endDate >= d.startDate, {
message: "End date must be after start date",
path: ["endDate"],
}),
)
.input(CreateVacationRequestSchema)
.mutation(async ({ ctx, input }) => {
if (input.type === VacationType.PUBLIC_HOLIDAY) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests",
});
}
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true, systemRole: true },
@@ -186,6 +311,9 @@ export const vacationRouter = createTRPCRouter({
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
...(BALANCE_TYPES.has(input.type)
? { type: { not: VacationType.PUBLIC_HOLIDAY } }
: {}),
},
});
if (overlapping) {
@@ -195,6 +323,35 @@ export const vacationRouter = createTRPCRouter({
});
}
let effectiveDays: number | null = null;
if (BALANCE_TYPES.has(input.type)) {
const holidayContext = await loadResourceHolidayContext(
ctx.db,
input.resourceId,
input.startDate,
input.endDate,
);
effectiveDays = countVacationChargeableDays({
vacation: {
startDate: input.startDate,
endDate: input.endDate,
isHalfDay: input.isHalfDay ?? false,
},
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
if (effectiveDays <= 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Selected vacation period only contains public holidays and does not deduct any vacation days",
});
}
}
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
const vacation = await ctx.db.vacation.create({
@@ -265,7 +422,8 @@ export const vacationRouter = createTRPCRouter({
}
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
const result = anonymizeVacationRecord(vacation, directory);
return effectiveDays === null ? result : { ...result, effectiveDays };
}),
/**
@@ -698,19 +856,25 @@ export const vacationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const holidays = getPublicHolidays(input.year, input.federalState);
if (holidays.length === 0) {
return { created: 0 };
}
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { id: true },
select: {
id: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
if (resources.length === 0) {
return { created: 0 };
}
const adminUser = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
@@ -718,8 +882,19 @@ export const vacationRouter = createTRPCRouter({
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
let created = 0;
let holidayCount = 0;
for (const resource of resources) {
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: input.federalState ?? resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
holidayCount += holidays.length;
for (const holiday of holidays) {
const startDate = new Date(holiday.date);
const endDate = new Date(holiday.date);
@@ -771,12 +946,12 @@ export const vacationRouter = createTRPCRouter({
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
action: "CREATE",
userId: adminUser.id,
after: { created, holidays: holidays.length, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
after: { created, holidays: holidayCount, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
source: "ui",
summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`,
});
return { created, holidays: holidays.length, resources: resources.length };
return { created, holidays: holidayCount, resources: resources.length };
}),
/**