import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js"; import { isChargeabilityActualBooking, isChargeabilityRelevantProject, listAssignmentBookings, recomputeResourceValueScores, } from "@capakraken/application"; import { calculateAllocation, deriveResourceForecast, getMonthRange, DEFAULT_CALCULATION_RULES, type AssignmentSlice, } from "@capakraken/engine"; import { VacationStatus } from "@capakraken/db"; import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, SystemRole, UpdateResourceSchema, inferStateFromPostalCode, resolvePermissions, type PermissionOverrides } from "@capakraken/shared"; import type { CalculationRule, SpainScheduleRule } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { anonymizeResource, anonymizeResources, anonymizeSearchMatches, getAnonymizationDirectory, resolveResourceIdsByDisplayedEids, } from "../lib/anonymization.js"; import { asHolidayResolverDb, collectHolidayAvailability, getResolvedCalendarHolidays, } from "../lib/holiday-availability.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, calculateEffectiveDayAvailability, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool. Artist profile: - Role: {role} - Chapter: {chapter} - Main skills: {mainSkills} - Top skills: {topSkills} Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.`; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission, resourceOverviewProcedure } from "../trpc.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; import type { TRPCContext } from "../trpc.js"; function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null { if (!cursor) return null; try { const decoded = JSON.parse(cursor) as { displayName?: string; id?: string }; if (typeof decoded.displayName === "string" && typeof decoded.id === "string") { return { displayName: decoded.displayName, id: decoded.id }; } } catch { return null; } 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[0]["context"], periodStart: Date, periodEnd: Date, ): Map { const dailyBookedHours = new Map(); 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; } 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); } function formatResolvedHolidaySummary(holiday: { date: string; name: string; scope: string; calendarName: string | null; sourceType?: string | null; }) { return { date: holiday.date, name: holiday.name, scope: holiday.scope, calendarName: holiday.calendarName ?? "Built-in", sourceType: holiday.sourceType ?? "system", }; } const RESOURCE_SUMMARY_SELECT = { id: true, eid: true, displayName: true, chapter: true, isActive: true, areaRole: { select: { name: true } }, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, orgUnit: { select: { name: true } }, } as const; const RESOURCE_SUMMARY_DETAIL_SELECT = { ...RESOURCE_SUMMARY_SELECT, fte: true, lcrCents: true, chargeabilityTarget: true, } as const; const RESOURCE_IDENTIFIER_SELECT = { id: true, eid: true, displayName: true, chapter: true, isActive: true, } as const; const RESOURCE_IDENTIFIER_DETAIL_SELECT = { ...RESOURCE_IDENTIFIER_SELECT, id: true, eid: true, displayName: true, email: true, chapter: true, fte: true, lcrCents: true, ucrCents: true, chargeabilityTarget: true, isActive: true, availability: true, skills: true, postalCode: true, federalState: true, areaRole: { select: { name: true, color: true } }, country: { select: { code: true, name: true, dailyWorkingHours: true } }, metroCity: { select: { name: true } }, managementLevelGroup: { select: { name: true, targetPercentage: true } }, orgUnit: { select: { name: true, level: true } }, _count: { select: { assignments: true, vacations: true } }, } as const; function mapResourceSummary(resource: { id: string; eid: string; displayName: string; chapter: string | null; isActive: boolean; areaRole: { name: string } | null; country: { code: string; name: string } | null; metroCity: { name: string } | null; orgUnit: { name: string } | null; }) { return { id: resource.id, eid: resource.eid, name: resource.displayName, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, metroCity: resource.metroCity?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, active: resource.isActive, }; } function mapResourceSummaryDetail(resource: { id: string; eid: string; displayName: string; chapter: string | null; fte: number | null; lcrCents: number | null; chargeabilityTarget: number | null; isActive: boolean; areaRole: { name: string } | null; country: { code: string; name: string } | null; metroCity: { name: string } | null; orgUnit: { name: string } | null; }) { return { id: resource.id, eid: resource.eid, name: resource.displayName, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, metroCity: resource.metroCity?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, fte: resource.fte, lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null, active: resource.isActive, }; } function mapResourceDetail(resource: { id: string; eid: string; displayName: string; email: string | null; chapter: string | null; fte: number | null; lcrCents: number | null; ucrCents: number | null; chargeabilityTarget: number | null; isActive: boolean; skills: unknown; postalCode: string | null; federalState: string | null; areaRole: { name: string; color: string | null } | null; country: { code: string; name: string; dailyWorkingHours: number | null } | null; metroCity: { name: string } | null; managementLevelGroup: { name: string; targetPercentage: number | null } | null; orgUnit: { name: string; level: number } | null; _count: { assignments: number; vacations: number }; }) { const skills = Array.isArray(resource.skills) ? resource.skills as { name?: string; level?: number }[] : []; return { id: resource.id, eid: resource.eid, name: resource.displayName, email: resource.email, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, countryHours: resource.country?.dailyWorkingHours ?? 8, metroCity: resource.metroCity?.name ?? null, fte: resource.fte, lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, ucr: resource.ucrCents != null ? fmtEur(resource.ucrCents) : null, chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null, managementLevel: resource.managementLevelGroup?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, postalCode: resource.postalCode, federalState: resource.federalState, active: resource.isActive, totalAssignments: resource._count.assignments, totalVacations: resource._count.vacations, skillCount: skills.length, topSkills: skills.slice(0, 10).map((skill) => `${skill.name ?? "?"} (${skill.level ?? "?"})`), }; } function summarizeResolvedHolidaySummary(holidays: Array>) { const byScope = new Map(); const bySourceType = new Map(); const byCalendar = new Map(); for (const holiday of holidays) { byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); } return { byScope: [...byScope.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([scope, count]) => ({ scope, count })), bySourceType: [...bySourceType.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([sourceType, count]) => ({ sourceType, count })), byCalendar: [...byCalendar.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([calendarName, count]) => ({ calendarName, count })), }; } function round1(value: number): number { return Math.round(value * 10) / 10; } function averagePerWorkingDay(totalHours: number, workingDays: number): number { return workingDays > 0 ? round1(totalHours / workingDays) : 0; } type ResourceReadContext = Pick; function resolveResourcePermissions(ctx: Pick): Set { if (!ctx.dbUser) { return new Set(); } return resolvePermissions( ctx.dbUser.systemRole as SystemRole, ctx.dbUser.permissionOverrides as PermissionOverrides | null, ctx.roleDefaults ?? undefined, ); } function canReadAllResources(ctx: Pick): boolean { const permissions = resolveResourcePermissions(ctx); return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES); } async function findOwnedResourceId(ctx: ResourceReadContext): Promise { if (!ctx.dbUser?.id) { return null; } if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { return null; } const resource = await ctx.db.resource.findFirst({ where: { userId: ctx.dbUser.id }, select: { id: true }, }); return resource?.id ?? null; } async function assertCanReadResource( ctx: ResourceReadContext, resourceId: string, message = "You can only view your own resource data", ): Promise { if (canReadAllResources(ctx)) { return; } const ownedResourceId = await findOwnedResourceId(ctx); if (!ownedResourceId || ownedResourceId !== resourceId) { throw new TRPCError({ code: "FORBIDDEN", message, }); } } function isBroadResourceLookupAllowed(ctx: Pick): boolean { return canReadAllResources(ctx); } const ResourceDirectoryQuerySchema = z.object({ chapter: z.string().optional(), chapters: z.array(z.string()).optional(), isActive: z.boolean().optional().default(true), search: z.string().optional(), eids: z.array(z.string()).optional(), countryIds: z.array(z.string()).optional(), excludedCountryIds: z.array(z.string()).optional(), includeWithoutCountry: z.boolean().optional().default(true), resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), includeWithoutResourceType: z.boolean().optional().default(true), rolledOff: z.boolean().optional(), departed: z.boolean().optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(500).default(50), cursor: z.string().optional(), }); const ResourceListQuerySchema = ResourceDirectoryQuerySchema.extend({ includeRoles: z.boolean().optional().default(false), customFieldFilters: z.array(z.object({ key: z.string(), value: z.string(), type: z.nativeEnum(FieldType), })).optional(), }); async function listStaffResources( ctx: Pick, input: z.infer, ) { const { chapter, chapters, isActive, search, eids, countryIds, excludedCountryIds, includeWithoutCountry, resourceTypes, excludedResourceTypes, includeWithoutResourceType, rolledOff, departed, page, limit, includeRoles, cursor, customFieldFilters, } = input; const parsedCursor = parseResourceCursor(cursor); const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields })); type WhereClause = Record; const andClauses: WhereClause[] = []; const chapterFilters = Array.from( new Set([ ...(chapter ? [chapter] : []), ...(chapters ?? []), ]), ); const directory = await getAnonymizationDirectory(ctx.db); if (!eids) { andClauses.push({ isActive }); } if (eids && !directory) { andClauses.push({ eid: { in: eids } }); } if (chapterFilters.length === 1) { andClauses.push({ chapter: chapterFilters[0] }); } else if (chapterFilters.length > 1) { andClauses.push({ chapter: { in: chapterFilters } }); } if (search && !directory) { andClauses.push({ OR: [ { displayName: { contains: search, mode: "insensitive" as const } }, { eid: { contains: search, mode: "insensitive" as const } }, { email: { contains: search, mode: "insensitive" as const } }, ], }); } if (countryIds && countryIds.length > 0) { const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }]; if (includeWithoutCountry) { countryClauses.push({ countryId: null }); } andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses }); } if (excludedCountryIds && excludedCountryIds.length > 0) { andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } }); } if (!includeWithoutCountry) { andClauses.push({ NOT: { countryId: null } }); } if (resourceTypes && resourceTypes.length > 0) { const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }]; if (includeWithoutResourceType) { resourceTypeClauses.push({ resourceType: null }); } andClauses.push( resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses }, ); } if (excludedResourceTypes && excludedResourceTypes.length > 0) { andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } }); } if (!includeWithoutResourceType) { andClauses.push({ NOT: { resourceType: null } }); } if (rolledOff !== undefined) { andClauses.push({ rolledOff }); } if (departed !== undefined) { andClauses.push({ departed }); } andClauses.push(...cfConditions); const where = andClauses.length > 0 ? { AND: andClauses } : {}; if (directory) { const rawResources = await (includeRoles ? ctx.db.resource.findMany({ where, include: { resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } }, }, }, orderBy: [{ displayName: "asc" }, { id: "asc" }], }) : ctx.db.resource.findMany({ where, orderBy: [{ displayName: "asc" }, { id: "asc" }], })); const directoryResources = rawResources.map((resource) => ({ id: resource.id, eid: resource.eid, displayName: resource.displayName, email: resource.email, })); const requestedIds = eids ? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids) : []; const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; const filteredResources = rawResources.filter((resource) => { const alias = directory.byResourceId.get(resource.id); if (requestedIdSet && !requestedIdSet.has(resource.id)) { return false; } if (eids && eids.length > 0 && requestedIds.length === 0) { return false; } if (search && !anonymizeSearchMatches( { id: resource.id, eid: resource.eid, displayName: resource.displayName, email: resource.email, }, alias, search, )) { return false; } return true; }); const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => { const displayNameCompare = left.displayName.localeCompare(right.displayName); if (displayNameCompare !== 0) { return displayNameCompare; } return left.id.localeCompare(right.id); }); const total = anonymizedResources.length; const afterCursor = parsedCursor ? anonymizedResources.filter( (resource) => resource.displayName > parsedCursor.displayName || (resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id), ) : anonymizedResources; const skip = cursor ? 0 : (page - 1) * limit; const paged = afterCursor.slice(skip, skip + limit + 1); const hasMore = paged.length > limit; const resources = hasMore ? paged.slice(0, limit) : paged; const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, id: resources[resources.length - 1]!.id, }) : null; return { resources, total, page, limit, nextCursor }; } const skip = cursor ? 0 : (page - 1) * limit; const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; const whereWithCursor = parsedCursor ? { AND: [ ...((where as { AND?: WhereClause[] }).AND ?? []), { OR: [ { displayName: { gt: parsedCursor.displayName } }, { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } }, ], }, ], } : where; const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy }; const [rawResources, total] = await Promise.all([ includeRoles ? ctx.db.resource.findMany({ ...baseQuery, include: { resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } }, }, }, }) : ctx.db.resource.findMany(baseQuery), ctx.db.resource.count({ where }), ]); const hasMore = rawResources.length > limit; const resources = hasMore ? rawResources.slice(0, limit) : rawResources; const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, id: resources[resources.length - 1]!.id, }) : null; return { resources, total, page, limit, nextCursor }; } function buildResourceSummaryWhere(input: { search?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive: boolean; }) { const where: Record = {}; if (input.isActive !== false) { where.isActive = true; } if (input.search) { where.OR = [ { displayName: { contains: input.search, mode: "insensitive" as const } }, { eid: { contains: input.search, mode: "insensitive" as const } }, { chapter: { contains: input.search, mode: "insensitive" as const } }, ]; } if (input.country) { where.country = { OR: [ { code: { equals: input.country, mode: "insensitive" as const } }, { name: { contains: input.country, mode: "insensitive" as const } }, ], }; } if (input.metroCity) { where.metroCity = { name: { contains: input.metroCity, mode: "insensitive" as const } }; } if (input.orgUnit) { where.orgUnit = { name: { contains: input.orgUnit, mode: "insensitive" as const } }; } if (input.roleName) { where.areaRole = { name: { contains: input.roleName, mode: "insensitive" as const } }; } return where; } function assertCanSearchResourceSummaries(ctx: Pick) { if (!canReadAllResources(ctx)) { throw new TRPCError({ code: "FORBIDDEN", message: "You need resource overview access to search resource summaries", }); } } async function readResourceSummariesSnapshot( ctx: Pick, input: { search?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive: boolean; limit: number; }, ) { assertCanSearchResourceSummaries(ctx); return ctx.db.resource.findMany({ where: buildResourceSummaryWhere(input), select: RESOURCE_SUMMARY_SELECT, take: input.limit, orderBy: { displayName: "asc" }, }); } async function readResourceSummaryDetailsSnapshot( ctx: Pick, input: { search?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive: boolean; limit: number; }, ) { assertCanSearchResourceSummaries(ctx); return ctx.db.resource.findMany({ where: buildResourceSummaryWhere(input), select: RESOURCE_SUMMARY_DETAIL_SELECT, take: input.limit, orderBy: { displayName: "asc" }, }); } async function resolveResourceIdentifierSnapshot( ctx: ResourceReadContext, identifier: string, forbiddenMessage = "You can only view your own resource unless you have staff access", ) { let resource = await ctx.db.resource.findUnique({ where: { id: identifier }, select: RESOURCE_IDENTIFIER_SELECT, }); if (!resource) { resource = await ctx.db.resource.findUnique({ where: { eid: identifier }, select: RESOURCE_IDENTIFIER_SELECT, }); } if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { equals: identifier, mode: "insensitive" } }, select: RESOURCE_IDENTIFIER_SELECT, }); } if (!resource && isBroadResourceLookupAllowed(ctx)) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: identifier, mode: "insensitive" } }, select: RESOURCE_IDENTIFIER_SELECT, }); } if (!resource && isBroadResourceLookupAllowed(ctx)) { const words = identifier.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); if (words.length > 0) { const candidates = await ctx.db.resource.findMany({ where: { OR: words.map((word) => ({ displayName: { contains: word, mode: "insensitive" as const }, })), }, select: RESOURCE_IDENTIFIER_SELECT, take: 5, }); if (candidates.length === 1) { resource = candidates[0]!; } else if (candidates.length > 1) { return { error: `Resource not found: "${identifier}". Did you mean one of these?`, suggestions: candidates.map((candidate) => ({ id: candidate.id, eid: candidate.eid, name: candidate.displayName, })), } as const; } } } if (!resource) { return { error: `Resource not found: ${identifier}` } as const; } await assertCanReadResource( ctx, resource.id, forbiddenMessage, ); return resource; } async function readResourceByIdentifierDetailSnapshot( ctx: ResourceReadContext, identifier: string, ) { const resource = await resolveResourceIdentifierSnapshot(ctx, identifier); if ("error" in resource) { return resource; } const detail = await ctx.db.resource.findUnique({ where: { id: resource.id }, select: RESOURCE_IDENTIFIER_DETAIL_SELECT, }); if (!detail) { return { error: `Resource not found: ${identifier}` } as const; } return detail; } export const resourceRouter = createTRPCRouter({ resolveByIdentifier: protectedProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => { const resource = await resolveResourceIdentifierSnapshot( ctx, input.identifier, "You can only resolve your own resource unless you have staff access", ); if ("error" in resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } return resource; }), resolveResponsiblePersonName: resourceOverviewProcedure .input(z.object({ name: z.string() })) .query(async ({ ctx, input }) => { const exact = await ctx.db.resource.findFirst({ where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, select: { displayName: true }, }); if (exact) { return { status: "resolved" as const, displayName: exact.displayName, }; } const candidates = await ctx.db.resource.findMany({ where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, select: { displayName: true, eid: true }, take: 5, }); if (candidates.length === 1) { return { status: "resolved" as const, displayName: candidates[0]!.displayName, }; } if (candidates.length > 1) { return { status: "ambiguous" as const, message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, candidates, }; } return { status: "missing" as const, message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, candidates: [], }; }), getChargeabilitySummary: protectedProcedure .input(z.object({ resourceId: z.string(), month: z.string().regex(/^\d{4}-\d{2}$/), })) .query(async ({ ctx, input }) => { await assertCanReadResource( ctx, input.resourceId, "You can only view chargeability details for your own resource unless you have staff access", ); const [year, month] = input.month.split("-").map(Number) as [number, number]; const { start: monthStart, end: monthEnd } = getMonthRange(year, month); const resource = await ctx.db.resource.findUniqueOrThrow({ where: { id: input.resourceId }, select: { id: true, displayName: true, eid: true, fte: true, lcrCents: true, chargeabilityTarget: true, countryId: true, federalState: true, metroCityId: true, availability: 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 } }, }, }); const dailyHours = resource.country?.dailyWorkingHours ?? 8; const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); const availability = resource.availability as WeekdayAvailability | null; const weeklyAvailability: WeekdayAvailability = availability ?? { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0, }; const assignments = await ctx.db.assignment.findMany({ where: { resourceId: input.resourceId, startDate: { lte: monthEnd }, endDate: { gte: monthStart }, status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, }, select: { id: true, hoursPerDay: true, startDate: true, endDate: true, dailyCostCents: true, status: true, project: { select: { id: true, name: true, shortCode: true, orderType: true, utilizationCategory: { select: { code: true } }, }, }, }, }); const vacations = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, status: VacationStatus.APPROVED, startDate: { lte: monthEnd }, endDate: { gte: monthStart }, }, 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 absenceDays = holidayAvailability.absenceDays; 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); 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 } 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 absenceDayEquivalent = absenceDays.reduce((sum, absence) => { if (absence.type === "PUBLIC_HOLIDAY") { return sum; } return sum + (absence.isHalfDay ? 0.5 : 1); }, 0); 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 slices: AssignmentSlice[] = []; const assignmentBreakdown: Array<{ project: string; code: string; hours: number; status: string; }> = []; let totalBookedHours = 0; for (const assignment of assignments) { const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; const calcResult = calculateAllocation({ lcrCents: resource.lcrCents, hoursPerDay: assignment.hoursPerDay, startDate: overlapStart, endDate: overlapEnd, availability: weeklyAvailability, absenceDays, calculationRules: calcRules, orderType: assignment.project.orderType, projectId: assignment.project.id, }); if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { continue; } totalBookedHours += calcResult.totalHours; assignmentBreakdown.push({ project: assignment.project.name, code: assignment.project.shortCode, hours: round1(calcResult.totalHours), status: assignment.status, }); slices.push({ hoursPerDay: assignment.hoursPerDay, workingDays: calcResult.workingDays, categoryCode, ...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}), }); } const forecast = deriveResourceForecast({ fte: resource.fte, targetPercentage: targetRatio, assignments: slices, sah: effectiveAvailableHours, }); const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ ...holiday, calendarName: holiday.calendarName ?? "Built-in", sourceType: holiday.sourceType ?? "system", })); const workingDays = round1(effectiveWorkingDays); const baseWorkingDaysRounded = round1(baseWorkingDays); const baseAvailableHoursRounded = round1(baseAvailableHours); const availableHours = round1(effectiveAvailableHours); const bookedHours = round1(totalBookedHours); const targetPct = round1(targetRatio * 100); const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; const chargeabilityPct = round1(forecast.chg * 100); const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); return { resource: resource.displayName, eid: resource.eid, month: input.month, periodStart: monthStart.toISOString().slice(0, 10), periodEnd: monthEnd.toISOString().slice(0, 10), fte: round1(resource.fte), target: `${targetPct}%`, targetPct, targetHours, workingDays, baseWorkingDays: baseWorkingDaysRounded, locationContext: { countryCode: resource.country?.code ?? null, country: resource.country?.name ?? resource.country?.code ?? null, federalState: resource.federalState ?? null, metroCity: resource.metroCity?.name ?? null, }, baseAvailableHours: baseAvailableHoursRounded, availableHours, bookedHours, unassignedHours, chargeability: `${chargeabilityPct}%`, chargeabilityPct, onTarget: chargeabilityPct >= targetPct, holidaySummary: { count: formattedHolidays.length, workdayCount: round1(publicHolidayWorkdayCount), hoursDeduction: round1(publicHolidayHoursDeduction), holidays: formattedHolidays, breakdown: summarizeResolvedHolidaySummary(formattedHolidays), }, absenceSummary: { dayEquivalent: round1(absenceDayEquivalent), hoursDeduction: round1(absenceHoursDeduction), }, capacityBreakdown: { formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", baseAvailableHours: baseAvailableHoursRounded, holidayHoursDeduction: round1(publicHolidayHoursDeduction), absenceHoursDeduction: round1(absenceHoursDeduction), availableHours, }, averages: { availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), }, allocations: assignmentBreakdown, scheduleContext: { dailyWorkingHours: dailyHours, hasScheduleRules: Boolean(scheduleRules), }, }; }), listSummaries: resourceOverviewProcedure .input(z.object({ search: z.string().optional(), country: z.string().optional(), metroCity: z.string().optional(), orgUnit: z.string().optional(), roleName: z.string().optional(), isActive: z.boolean().optional().default(true), limit: z.number().int().min(1).max(100).default(50), })) .query(async ({ ctx, input }) => { const resources = await readResourceSummariesSnapshot(ctx, { isActive: input.isActive, limit: input.limit, ...(input.search ? { search: input.search } : {}), ...(input.country ? { country: input.country } : {}), ...(input.metroCity ? { metroCity: input.metroCity } : {}), ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), ...(input.roleName ? { roleName: input.roleName } : {}), }); return resources.map(mapResourceSummary); }), listSummariesDetail: resourceOverviewProcedure .input(z.object({ search: z.string().optional(), country: z.string().optional(), metroCity: z.string().optional(), orgUnit: z.string().optional(), roleName: z.string().optional(), isActive: z.boolean().optional().default(true), limit: z.number().int().min(1).max(100).default(50), })) .query(async ({ ctx, input }) => { const resources = await readResourceSummaryDetailsSnapshot(ctx, { isActive: input.isActive, limit: input.limit, ...(input.search ? { search: input.search } : {}), ...(input.country ? { country: input.country } : {}), ...(input.metroCity ? { metroCity: input.metroCity } : {}), ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), ...(input.roleName ? { roleName: input.roleName } : {}), }); return resources.map(mapResourceSummaryDetail); }), directory: protectedProcedure .input(ResourceDirectoryQuerySchema) .query(async ({ ctx, input }) => { const { chapter, chapters, isActive, search, eids, countryIds, excludedCountryIds, includeWithoutCountry, resourceTypes, excludedResourceTypes, includeWithoutResourceType, rolledOff, departed, page, limit, cursor, } = input; const parsedCursor = parseResourceCursor(cursor); type WhereClause = Record; const andClauses: WhereClause[] = []; const chapterFilters = Array.from( new Set([ ...(chapter ? [chapter] : []), ...(chapters ?? []), ]), ); const directory = await getAnonymizationDirectory(ctx.db); if (!eids) { andClauses.push({ isActive }); } if (eids && !directory) { andClauses.push({ eid: { in: eids } }); } if (chapterFilters.length === 1) { andClauses.push({ chapter: chapterFilters[0] }); } else if (chapterFilters.length > 1) { andClauses.push({ chapter: { in: chapterFilters } }); } if (search && !directory) { andClauses.push({ OR: [ { displayName: { contains: search, mode: "insensitive" as const } }, { eid: { contains: search, mode: "insensitive" as const } }, ], }); } if (countryIds && countryIds.length > 0) { const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }]; if (includeWithoutCountry) { countryClauses.push({ countryId: null }); } andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses }); } if (excludedCountryIds && excludedCountryIds.length > 0) { andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } }); } if (!includeWithoutCountry) { andClauses.push({ NOT: { countryId: null } }); } if (resourceTypes && resourceTypes.length > 0) { const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }]; if (includeWithoutResourceType) { resourceTypeClauses.push({ resourceType: null }); } andClauses.push( resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses }, ); } if (excludedResourceTypes && excludedResourceTypes.length > 0) { andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } }); } if (!includeWithoutResourceType) { andClauses.push({ NOT: { resourceType: null } }); } if (rolledOff !== undefined) { andClauses.push({ rolledOff }); } if (departed !== undefined) { andClauses.push({ departed }); } const where = andClauses.length > 0 ? { AND: andClauses } : {}; const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; if (directory) { const rawResources = await ctx.db.resource.findMany({ where, select: { id: true, eid: true, displayName: true, chapter: true, isActive: true, email: true, }, orderBy, }); const requestedIds = eids ? resolveResourceIdsByDisplayedEids(rawResources, directory, eids) : []; const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; const filteredResources = rawResources.filter((resource) => { const alias = directory.byResourceId.get(resource.id); if (requestedIdSet && !requestedIdSet.has(resource.id)) { return false; } if (eids && eids.length > 0 && requestedIds.length === 0) { return false; } if (search && !anonymizeSearchMatches( { id: resource.id, eid: resource.eid, displayName: resource.displayName, email: resource.email, }, alias, search, )) { return false; } return true; }); const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => { const displayNameCompare = left.displayName.localeCompare(right.displayName); if (displayNameCompare !== 0) { return displayNameCompare; } return left.id.localeCompare(right.id); }); const total = anonymizedResources.length; const afterCursor = parsedCursor ? anonymizedResources.filter( (resource) => resource.displayName > parsedCursor.displayName || (resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id), ) : anonymizedResources; const skip = cursor ? 0 : (page - 1) * limit; const paged = afterCursor.slice(skip, skip + limit + 1); const hasMore = paged.length > limit; const resources = (hasMore ? paged.slice(0, limit) : paged).map((resource) => ({ id: resource.id, eid: resource.eid, displayName: resource.displayName, chapter: resource.chapter, isActive: resource.isActive, })); const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, id: resources[resources.length - 1]!.id, }) : null; return { resources, total, page, limit, nextCursor }; } const skip = cursor ? 0 : (page - 1) * limit; const whereWithCursor = parsedCursor ? { AND: [ ...((where as { AND?: WhereClause[] }).AND ?? []), { OR: [ { displayName: { gt: parsedCursor.displayName } }, { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } }, ], }, ], } : where; const [rawResources, total] = await Promise.all([ ctx.db.resource.findMany({ where: whereWithCursor, skip, take: limit + 1, orderBy, select: { id: true, eid: true, displayName: true, chapter: true, isActive: true, }, }), ctx.db.resource.count({ where }), ]); const hasMore = rawResources.length > limit; const resources = hasMore ? rawResources.slice(0, limit) : rawResources; const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, id: resources[resources.length - 1]!.id, }) : null; return { resources, total, page, limit, nextCursor }; }), listStaff: resourceOverviewProcedure .input(ResourceListQuerySchema) .query(async ({ ctx, input }) => listStaffResources(ctx, input)), /** Lightweight resource card for hover tooltips on the timeline. */ getHoverCard: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.id }, select: { id: true, displayName: true, eid: true, email: true, chapter: true, lcrCents: true, ucrCents: true, currency: true, chargeabilityTarget: true, skills: true, availability: true, isActive: true, areaRole: { select: ROLE_BRIEF_SELECT }, country: { select: { name: true, code: true } }, managementLevel: { select: { name: true } }, resourceType: true, }, }), "Resource", ); await assertCanReadResource( ctx, resource.id, "You can only view hover details for your own resource unless you have staff access", ); const directory = await getAnonymizationDirectory(ctx.db); const anon = anonymizeResource(resource, directory); return { id: anon.id, displayName: anon.displayName ?? "", eid: anon.eid ?? "", chapter: resource.chapter, lcrCents: resource.lcrCents, ucrCents: resource.ucrCents, currency: resource.currency, chargeabilityTarget: resource.chargeabilityTarget, skills: resource.skills as Record[], isActive: resource.isActive, resourceType: resource.resourceType, areaRole: resource.areaRole, country: resource.country, managementLevel: resource.managementLevel, }; }), getByIdentifier: protectedProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => resolveResourceIdentifierSnapshot(ctx, input.identifier)), getByIdentifierDetail: protectedProcedure .input(z.object({ identifier: z.string() })) .query(async ({ ctx, input }) => { const resource = await readResourceByIdentifierDetailSnapshot(ctx, input.identifier); if ("error" in resource) { return resource; } return mapResourceDetail(resource); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.id }, include: { blueprint: true, resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } }, }, areaRole: { select: { id: true, name: true } }, }, }), "Resource", ); await assertCanReadResource( ctx, resource.id, "You can only view your own resource unless you have staff access", ); const directory = await getAnonymizationDirectory(ctx.db); return { ...anonymizeResource(resource, directory), isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id), }; }), getByEid: protectedProcedure .input(z.object({ eid: z.string() })) .query(async ({ ctx, input }) => { const directory = await getAnonymizationDirectory(ctx.db); let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } }); if (!resource && directory) { const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase()); if (resourceId) { resource = await ctx.db.resource.findUnique({ where: { id: resourceId } }); } } if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); } await assertCanReadResource( ctx, resource.id, "You can only view your own resource unless you have staff access", ); return anonymizeResource(resource, directory); }), create: managerProcedure .input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); const existing = await ctx.db.resource.findFirst({ where: { OR: [{ eid: input.eid }, { email: input.email }] }, }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`, }); } await assertBlueprintDynamicFields({ db: ctx.db, blueprintId: input.blueprintId, dynamicFields: input.dynamicFields, target: BlueprintTarget.RESOURCE, }); // Enforce max 1 primary role const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length; if (primaryCount > 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" }); } const resource = await ctx.db.resource.create({ data: { eid: input.eid, displayName: input.displayName, email: input.email, chapter: input.chapter, lcrCents: input.lcrCents, ucrCents: input.ucrCents, currency: input.currency, chargeabilityTarget: input.chargeabilityTarget, availability: input.availability, skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue, blueprintId: input.blueprintId, portfolioUrl: input.portfolioUrl || undefined, roleId: input.roleId || undefined, ...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}), ...(input.postalCode && !input.federalState ? { federalState: inferStateFromPostalCode(input.postalCode) } : input.federalState !== undefined ? { federalState: input.federalState } : {}), ...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}), ...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}), ...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}), ...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}), ...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}), ...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}), ...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}), ...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}), ...(input.departed !== undefined ? { departed: input.departed } : {}), ...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}), ...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}), ...(input.fte !== undefined ? { fte: input.fte } : {}), resourceRoles: input.roles?.length ? { create: input.roles.map((r) => ({ roleId: r.roleId, isPrimary: r.isPrimary, })), } : undefined, } as unknown as Parameters[0]["data"], include: { resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, }, }); await ctx.db.auditLog.create({ data: { entityType: "Resource", entityId: resource.id, action: "CREATE", userId: ctx.dbUser?.id, changes: { after: resource }, } as unknown as Parameters[0]["data"], }); return resource; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); const existing = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.id } }), "Resource", ); const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined; const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record; await assertBlueprintDynamicFields({ db: ctx.db, blueprintId: nextBlueprintId, dynamicFields: nextDynamicFields, target: BlueprintTarget.RESOURCE, }); // Enforce max 1 primary role if (input.data.roles !== undefined) { const primaryCount = input.data.roles.filter((r) => r.isPrimary).length; if (primaryCount > 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" }); } } const updated = await ctx.db.resource.update({ where: { id: input.id }, data: { ...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}), ...(input.data.email !== undefined ? { email: input.data.email } : {}), ...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}), ...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}), ...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}), ...(input.data.currency !== undefined ? { currency: input.data.currency } : {}), ...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}), ...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), ...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}), ...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}), ...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}), ...(input.data.postalCode && !input.data.federalState ? { federalState: inferStateFromPostalCode(input.data.postalCode) } : input.data.federalState !== undefined ? { federalState: input.data.federalState } : {}), ...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}), ...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}), ...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}), ...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}), ...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}), ...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}), ...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}), ...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}), ...(input.data.departed !== undefined ? { departed: input.data.departed } : {}), ...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}), ...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}), ...(input.data.fte !== undefined ? { fte: input.data.fte } : {}), } as unknown as Parameters[0]["data"], include: { resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } }, }, }); // Replace roles if provided if (input.data.roles !== undefined) { await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } }); if (input.data.roles.length > 0) { await ctx.db.resourceRole.createMany({ data: input.data.roles.map((r) => ({ resourceId: input.id, roleId: r.roleId, isPrimary: r.isPrimary, })), }); } } await ctx.db.auditLog.create({ data: { entityType: "Resource", entityId: input.id, action: "UPDATE", changes: { before: existing, after: updated }, }, }); return updated; }), deactivate: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); const resource = await ctx.db.resource.update({ where: { id: input.id }, data: { isActive: false }, }); await ctx.db.auditLog.create({ data: { entityType: "Resource", entityId: input.id, action: "UPDATE", changes: { after: { isActive: false } }, }, }); return resource; }), batchDeactivate: managerProcedure .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); const updated = await ctx.db.$transaction( input.ids.map((id) => ctx.db.resource.update({ where: { id }, data: { isActive: false } }), ), ); await ctx.db.auditLog.create({ data: { entityType: "Resource", entityId: input.ids.join(","), action: "UPDATE", changes: { after: { isActive: false, ids: input.ids } }, }, }); return { count: updated.length }; }), chapters: protectedProcedure.query(async ({ ctx }) => { const resources = await ctx.db.resource.findMany({ where: { isActive: true, chapter: { not: null } }, select: { chapter: true }, distinct: ["chapter"], orderBy: { chapter: "asc" }, }); return resources.map((r) => r.chapter as string); }), // ─── Skill Matrix Import ──────────────────────────────────────────────────── importSkillMatrix: protectedProcedure .input( z.object({ skills: z.array(SkillEntrySchema), employeeInfo: z .object({ roleId: z.string().optional(), yearsOfExperience: z.number().optional(), portfolioUrl: z.string().url().optional().or(z.literal("")), }) .optional(), }), ) .mutation(async ({ ctx, input }) => { // Find the resource linked to this user const user = await findUniqueOrThrow( ctx.db.user.findUnique({ where: { email: ctx.session.user?.email ?? "" }, include: { resource: true }, }), "User", ); if (!user.resource) { throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" }); } const resourceId = user.resource.id; await ctx.db.resource.update({ where: { id: resourceId }, data: { skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, skillMatrixUpdatedAt: new Date(), ...(input.employeeInfo?.portfolioUrl !== undefined ? { portfolioUrl: input.employeeInfo.portfolioUrl || null } : {}), ...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}), }, }); return { count: input.skills.length }; }), importSkillMatrixForResource: managerProcedure .input( z.object({ resourceId: z.string(), skills: z.array(SkillEntrySchema), employeeInfo: z .object({ roleId: z.string().optional(), yearsOfExperience: z.number().optional(), portfolioUrl: z.string().url().optional().or(z.literal("")), }) .optional(), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId } }), "Resource", ); await ctx.db.resource.update({ where: { id: input.resourceId }, data: { skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, skillMatrixUpdatedAt: new Date(), ...(input.employeeInfo?.portfolioUrl !== undefined ? { portfolioUrl: input.employeeInfo.portfolioUrl || null } : {}), ...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}), }, }); return { count: input.skills.length }; }), batchImportSkillMatrices: adminProcedure .input( z.object({ entries: z.array( z.object({ eid: z.string(), skills: z.array(SkillEntrySchema), employeeInfo: z .object({ roleId: z.string().optional(), yearsOfExperience: z.number().optional(), portfolioUrl: z.string().url().optional().or(z.literal("")), }) .optional(), }), ), }), ) .mutation(async ({ ctx, input }) => { // Single findMany to avoid N+1 (was: findUnique per entry) const eids = input.entries.map((e) => e.eid); const existing = await ctx.db.resource.findMany({ where: { eid: { in: eids } }, select: { id: true, eid: true }, }); const eidToId = new Map(existing.map((r) => [r.eid, r.id])); const notFound = input.entries.length - existing.length; const now = new Date(); const updates = input.entries .filter((entry) => eidToId.has(entry.eid)) .map((entry) => ctx.db.resource.update({ where: { id: eidToId.get(entry.eid)! }, data: { skills: entry.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue, skillMatrixUpdatedAt: now, ...(entry.employeeInfo?.portfolioUrl !== undefined ? { portfolioUrl: entry.employeeInfo.portfolioUrl || null } : {}), ...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}), }, }), ); await ctx.db.$transaction(updates); return { updated: updates.length, notFound }; }), // ─── AI Summary ───────────────────────────────────────────────────────────── generateAiSummary: managerProcedure .input(z.object({ resourceId: z.string() })) .mutation(async ({ ctx, input }) => { const [resource, settings] = await Promise.all([ findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId }, include: { areaRole: { select: { name: true } } }, }), "Resource", ), ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }), ]); if (!isAiConfigured(settings)) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "AI is not configured. Please set credentials in Admin → Settings.", }); } type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; const skills = (resource.skills as unknown as SkillRow[]) ?? []; const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill); const top10 = [...skills] .sort((a, b) => b.proficiency - a.proficiency) .slice(0, 10) .map((s) => `${s.skill} (${s.proficiency}/5)`); const vars = { role: resource.areaRole?.name ?? "Not specified", chapter: resource.chapter ?? "Not specified", mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified", topSkills: top10.join(", "), }; const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT; const prompt = templateStr .replace("{role}", vars.role) .replace("{chapter}", vars.chapter) .replace("{mainSkills}", vars.mainSkills) .replace("{topSkills}", vars.topSkills); const client = createAiClient(settings!); const model = settings!.azureOpenAiDeployment!; const maxTokens = settings!.aiMaxCompletionTokens ?? 300; const temperature = settings!.aiTemperature ?? 1; const provider = settings!.aiProvider ?? "openai"; async function callChatCompletions(withTemperature: boolean) { return loggedAiCall(provider, model, prompt.length, () => client.chat.completions.create({ messages: [{ role: "user", content: prompt }], max_completion_tokens: maxTokens, model, ...(withTemperature && temperature !== 1 ? { temperature } : {}), }), ); } let summary = ""; try { let completion; try { completion = await callChatCompletions(true); console.log("[generateAiSummary] chat.completions response:", JSON.stringify({ choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })), })); } catch (tempErr) { const status = (tempErr as { status?: number }).status; const msg = (tempErr as Error).message ?? ""; console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200)); if (status === 400 && msg.includes("temperature")) { completion = await callChatCompletions(false); } else if (status === 404) { console.log("[generateAiSummary] falling back to responses API"); const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens }); console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100)); summary = resp.output_text?.trim() ?? ""; completion = null; } else { throw tempErr; } } if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? ""; } catch (e) { throw e; } await ctx.db.resource.update({ where: { id: input.resourceId }, data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() }, }); return { summary }; }), // ─── Skills Analytics ─────────────────────────────────────────────────────── getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => { const resources = await ctx.db.resource.findMany({ where: { isActive: true }, select: { id: true, displayName: true, chapter: true, skills: true }, }); type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; // Aggregate: { skillName, category, count, totalProficiency, chapters } const skillMap = new Map< string, { skill: string; category: string; count: number; totalProficiency: number; chapters: Set } >(); for (const resource of resources) { const skills = (resource.skills as unknown as SkillRow[]) ?? []; for (const s of skills) { const key = s.skill; if (!skillMap.has(key)) { skillMap.set(key, { skill: s.skill, category: s.category ?? "Uncategorized", count: 0, totalProficiency: 0, chapters: new Set(), }); } const entry = skillMap.get(key)!; entry.count++; entry.totalProficiency += s.proficiency; if (resource.chapter) entry.chapters.add(resource.chapter); } } const aggregated = Array.from(skillMap.values()) .map((e) => ({ skill: e.skill, category: e.category, count: e.count, avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, chapters: Array.from(e.chapters), })) .sort((a, b) => b.count - a.count); const categories = [...new Set(aggregated.map((e) => e.category))].sort(); const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[]; return { totalResources: resources.length, totalSkillEntries: aggregated.length, aggregated, categories, allChapters, }; }), searchBySkills: controllerProcedure .input( z.object({ rules: z.array( z.object({ skill: z.string().min(1), minProficiency: z.number().int().min(1).max(5).default(1), }), ), chapter: z.string().optional(), operator: z.enum(["AND", "OR"]).default("AND"), }), ) .query(async ({ ctx, input }) => { const { rules, chapter, operator } = input; const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(chapter ? { chapter } : {}) }, select: { id: true, eid: true, displayName: true, chapter: true, skills: true }, }); type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; const results = resources .map((r) => { const skills = (r.skills as unknown as SkillRow[]) ?? []; const matchFn = (rule: { skill: string; minProficiency: number }) => { const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase())); return s && s.proficiency >= rule.minProficiency ? s : null; }; const matched = rules.map(matchFn); const passes = operator === "AND" ? matched.every(Boolean) : matched.some(Boolean); if (!passes) return null; return { id: r.id, eid: r.eid, displayName: r.displayName, chapter: r.chapter, matchedSkills: rules .map((rule, i) => { const s = matched[i]; return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null; }) .filter((s): s is { skill: string; proficiency: number; category: string } => s !== null), }; }) .filter((r): r is NonNullable => r !== null) .sort((a, b) => a.displayName.localeCompare(b.displayName)); const directory = await getAnonymizationDirectory(ctx.db); return anonymizeResources(results, directory); }), // ─── Self-service ──────────────────────────────────────────────────────────── /** Get the resource linked to the current user (for self-service pages). */ getMyResource: protectedProcedure.query(async ({ ctx }) => { const email = ctx.session.user?.email; if (!email) return null; const user = await ctx.db.user.findUnique({ where: { email }, select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } }, }); const directory = await getAnonymizationDirectory(ctx.db); return user?.resource ? anonymizeResource(user.resource, directory) : null; }), // ─── Value Score ───────────────────────────────────────────────────────────── getValueScores: protectedProcedure .input( z.object({ isActive: z.boolean().optional().default(true), limit: z.number().int().min(1).max(500).default(100), }), ) .query(async ({ ctx, input }) => { const permissions = resolveResourcePermissions(ctx); requirePermission({ permissions }, PermissionKey.VIEW_SCORES); const resources = await ctx.db.resource.findMany({ where: { isActive: input.isActive }, select: { id: true, eid: true, displayName: true, chapter: true, lcrCents: true, valueScore: true, valueScoreBreakdown: true, valueScoreUpdatedAt: true, }, orderBy: [{ valueScore: "desc" }, { displayName: "asc" }], take: input.limit, }); const directory = await getAnonymizationDirectory(ctx.db); return anonymizeResources(resources, directory); }), recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => { return recomputeResourceValueScores(ctx.db); }), listWithUtilization: controllerProcedure .input( z.object({ startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), chapter: z.string().optional(), includeProposed: z.boolean().default(false), limit: z.number().int().min(1).max(500).default(100), }), ) .query(async ({ ctx, input }) => { const now = new Date(); const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1); const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0); const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, take: input.limit, orderBy: { displayName: "asc" }, select: { id: true, eid: true, displayName: true, email: true, chapter: true, lcrCents: true, ucrCents: true, currency: true, chargeabilityTarget: true, availability: true, skills: true, dynamicFields: true, blueprintId: true, isActive: true, createdAt: true, updatedAt: true, roleId: true, 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, { startDate: start, endDate: end, resourceIds: resources.map((resource) => resource.id), }); const bookingsByResourceId = new Map(); 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 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"), ); 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; return anonymizeResource({ ...r, bookingCount: resourceBookings.length, bookedHours: Math.round(bookedHours), availableHours: Math.round(availableHours), utilizationPercent, isOverbooked, }, directory); }); }), getChargeabilityStats: controllerProcedure .input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() })) .query(async ({ ctx, input }) => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), 1); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.resourceId ? { id: input.resourceId } : {}), }, select: { id: true, eid: true, displayName: true, 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, { startDate: start, 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) => isChargeabilityActualBooking(booking, input.includeProposed), ); const expectedAllocs = resourceBookings.filter((booking) => isChargeabilityRelevantProject(booking.project, true), ); 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, eid: r.eid, displayName: r.displayName, chapter: r.chapter, chargeabilityTarget: r.chargeabilityTarget, actualChargeability, expectedChargeability, availableHours: Math.round(availableHours), }, directory); }); }), /** * Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys). */ batchUpdateCustomFields: managerProcedure .input(z.object({ ids: z.array(z.string()).min(1).max(100), fields: z.record(z.string(), z.unknown()), })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); await ctx.db.$transaction( input.ids.map((id) => ctx.db.$executeRaw` UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb WHERE id = ${id} `, ), ); await ctx.db.auditLog.create({ data: { entityType: "Resource", entityId: input.ids.join(","), action: "UPDATE", changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue, }, }); return { updated: input.ids.length }; }), // ─── Skill Marketplace ──────────────────────────────────────────────────── getSkillMarketplace: controllerProcedure .input( z.object({ // Section 1: Skill search searchSkill: z.string().optional(), minProficiency: z.number().int().min(1).max(5).optional().default(1), availableOnly: z.boolean().optional().default(false), }), ) .query(async ({ ctx, input }) => { const now = new Date(); const 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 }; // ── Fetch all active resources with skills ── const resources = await ctx.db.resource.findMany({ where: { isActive: true }, select: { id: true, displayName: true, eid: true, chapter: true, skills: true, availability: true, chargeabilityTarget: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }); // ── Fetch current assignments for utilization calc ── const allResourceIds = resources.map((r) => r.id); const assignments = await ctx.db.assignment.findMany({ where: { resourceId: { in: allResourceIds }, status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] }, endDate: { gte: today }, startDate: { lte: thirtyDaysFromNow }, }, select: { resourceId: true, startDate: true, endDate: true, 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(); for (const assignment of assignments) { const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; items.push(assignment); assignmentsByResourceId.set(assignment.resourceId, items); } // Build utilization map with holiday-aware daily capacity over the next 30 days. const utilizationMap = new Map(); for (const r of resources) { 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, ); let earliestAvailableDate: Date | null = null; const checkDate = new Date(today); for (let i = 0; i < 30; i++) { 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.setUTCDate(checkDate.getUTCDate() + 1); } utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate }); } // ── Section 1: Skill Search ── let searchResults: Array<{ id: string; displayName: string; chapter: string | null; skillProficiency: number; skillName: string; utilizationPercent: number; availableFrom: string | null; }> = []; if (input.searchSkill && input.searchSkill.trim().length > 0) { const needle = input.searchSkill.toLowerCase(); for (const r of resources) { const skills = (r.skills as unknown as SkillRow[]) ?? []; const match = skills.find( (s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency, ); if (!match) continue; const util = utilizationMap.get(r.id); if (input.availableOnly && !util?.earliestAvailableDate) continue; searchResults.push({ id: r.id, displayName: r.displayName, chapter: r.chapter, skillProficiency: match.proficiency, skillName: match.skill, utilizationPercent: util?.utilizationPercent ?? 0, availableFrom: util?.earliestAvailableDate?.toISOString() ?? null, }); } searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent); } // ── Section 2: Skill Gap Heat Map ── // Demand: from unfilled DemandRequirements + project staffingReqs skills const unfilled = await ctx.db.demandRequirement.findMany({ where: { endDate: { gte: now }, assignments: { none: {} }, }, select: { id: true, role: true, roleId: true, headcount: true, project: { select: { staffingReqs: true }, }, }, }); // Collect demanded skills from project staffingReqs const demandSkillCounts = new Map(); for (const demand of unfilled) { const staffingReqs = (demand.project.staffingReqs as unknown as Array<{ role?: string; roleId?: string; requiredSkills?: string[]; }>) ?? []; // Match demand to staffing req by role or roleId const matchedReq = staffingReqs.find( (sr) => (demand.roleId && sr.roleId === demand.roleId) || (demand.role && sr.role === demand.role), ); if (matchedReq?.requiredSkills) { for (const skill of matchedReq.requiredSkills) { demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount); } } } // Supply: count resources with skill at proficiency >= 3 const supplySkillCounts = new Map(); const allSkillCounts = new Map(); for (const r of resources) { const skills = (r.skills as unknown as SkillRow[]) ?? []; for (const s of skills) { allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1); if (s.proficiency >= 3) { supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1); } } } // Merge all skill names from both demand and supply const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]); const gapData = Array.from(allGapSkills) .map((skill) => { const supply = supplySkillCounts.get(skill) ?? 0; const demand = demandSkillCounts.get(skill) ?? 0; return { skill, supply, demand, gap: demand - supply }; }) .sort((a, b) => b.gap - a.gap); // ── Section 3: Distribution (top 20 by resource count) ── const aggregated = Array.from( (() => { const map = new Map(); for (const r of resources) { const skills = (r.skills as unknown as SkillRow[]) ?? []; for (const s of skills) { const entry = map.get(s.skill); if (entry) { entry.count++; entry.totalProficiency += s.proficiency; } else { map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency }); } } } return map; })().values(), ) .map((e) => ({ skill: e.skill, count: e.count, avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, })) .sort((a, b) => b.count - a.count) .slice(0, 20); const directory = await getAnonymizationDirectory(ctx.db); return { searchResults: anonymizeResources(searchResults, directory), gapData, distribution: aggregated, totalResources: resources.length, }; }), });