6f34659587
Auto-Staffing Suggestions (A6): - generateAutoSuggestions() ranks top-3 resources on demand creation - Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%) - Creates in-app notification with match scores for managers - Triggered after createDemandRequirement and partial fillDemandRequirement Vacation Conflict Detection (A7): - checkVacationConflicts() warns when >50% chapter absent on same days - Returns warnings array in approve/batchApprove responses (advisory, non-blocking) - Creates VACATION_CONFLICT_WARNING notification for approver Weekly Chargeability Alerts (A10): - checkChargeabilityAlerts() finds resources >15pp below target - Cron endpoint: GET /api/cron/chargeability-alerts - Duplicate-safe by resourceId + month composite key Rate Card Auto-Apply (A11): - lookupRate() finds best matching rate card line (weighted scoring) - Auto-fills demand line rates in estimate create/updateDraft when rates are 0 - Marks auto-filled lines with metadata.autoAppliedRateCard - New lookupDemandLineRate query for on-demand UI lookups Public Holiday Auto-Import (A12): - autoImportPublicHolidays() generates holidays by resource federal state - Cron endpoint: GET /api/cron/public-holidays?year=2027 - Duplicate-safe, uses existing getPublicHolidays() from shared Skill Marketplace MVP (G6): - New page: /analytics/skill-marketplace with 3 sections - Skill Search: filter by name, proficiency, availability, sortable results - Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators - Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart) - New getSkillMarketplace query in resource router - Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER Co-Authored-By: claude-flow <ruv@ruv.net>
1419 lines
53 KiB
TypeScript
1419 lines
53 KiB
TypeScript
import { createAiClient, isAiConfigured } from "../ai-client.js";
|
||
import {
|
||
isChargeabilityActualBooking,
|
||
isChargeabilityRelevantProject,
|
||
listAssignmentBookings,
|
||
recomputeResourceValueScores,
|
||
} from "@planarchy/application";
|
||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@planarchy/shared";
|
||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||
import { computeChargeability } from "@planarchy/engine";
|
||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||
import {
|
||
anonymizeResource,
|
||
anonymizeResources,
|
||
anonymizeSearchMatches,
|
||
getAnonymizationDirectory,
|
||
resolveResourceIdsByDisplayedEids,
|
||
} from "../lib/anonymization.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 } from "../trpc.js";
|
||
import { ROLE_BRIEF_SELECT } from "../db/selects.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;
|
||
}
|
||
|
||
export const resourceRouter = createTRPCRouter({
|
||
list: protectedProcedure
|
||
.input(
|
||
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),
|
||
includeRoles: z.boolean().optional().default(false),
|
||
// Cursor-based pagination (additive — page/limit still supported)
|
||
cursor: z.string().optional(),
|
||
// Custom field JSONB filters
|
||
customFieldFilters: z.array(z.object({
|
||
key: z.string(),
|
||
value: z.string(),
|
||
type: z.nativeEnum(FieldType),
|
||
})).optional(),
|
||
}),
|
||
)
|
||
.query(async ({ ctx, input }) => {
|
||
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<string, unknown>;
|
||
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 };
|
||
}),
|
||
|
||
/** 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",
|
||
);
|
||
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<string, unknown>[],
|
||
isActive: resource.isActive,
|
||
resourceType: resource.resourceType,
|
||
areaRole: resource.areaRole,
|
||
country: resource.country,
|
||
managementLevel: resource.managementLevel,
|
||
};
|
||
}),
|
||
|
||
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",
|
||
);
|
||
|
||
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" });
|
||
}
|
||
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("@planarchy/db").Prisma.InputJsonValue,
|
||
dynamicFields: input.dynamicFields as unknown as import("@planarchy/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<typeof ctx.db.resource.create>[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<typeof ctx.db.auditLog.create>[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<string, unknown>;
|
||
|
||
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("@planarchy/db").Prisma.InputJsonValue } : {}),
|
||
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
|
||
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/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<typeof ctx.db.resource.update>[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("@planarchy/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("@planarchy/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("@planarchy/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;
|
||
|
||
async function callChatCompletions(withTemperature: boolean) {
|
||
return 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<string> }
|
||
>();
|
||
|
||
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<typeof r> => 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 settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
|
||
const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
|
||
if (!visibleRoles.includes(userRole)) return [];
|
||
|
||
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,
|
||
valueScore: true,
|
||
valueScoreBreakdown: true,
|
||
valueScoreUpdatedAt: true,
|
||
userId: true,
|
||
},
|
||
});
|
||
const bookings = await listAssignmentBookings(ctx.db, {
|
||
startDate: start,
|
||
endDate: end,
|
||
resourceIds: resources.map((resource) => resource.id),
|
||
});
|
||
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(
|
||
(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 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,
|
||
},
|
||
});
|
||
const bookings = await listAssignmentBookings(ctx.db, {
|
||
startDate: start,
|
||
endDate: end,
|
||
resourceIds: resources.map((resource) => resource.id),
|
||
});
|
||
const directory = await getAnonymizationDirectory(ctx.db);
|
||
|
||
return resources.map((r) => {
|
||
const avail = r.availability as unknown as WeekdayAvailability;
|
||
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 actual = computeChargeability(avail, actualAllocs, start, end);
|
||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
||
|
||
return anonymizeResource({
|
||
id: r.id,
|
||
eid: r.eid,
|
||
displayName: r.displayName,
|
||
chapter: r.chapter,
|
||
chargeabilityTarget: r.chargeabilityTarget,
|
||
actualChargeability: actual.chargeability,
|
||
expectedChargeability: expected.chargeability,
|
||
availableHours: actual.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("@planarchy/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 thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||
|
||
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,
|
||
},
|
||
});
|
||
|
||
// ── 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: now },
|
||
startDate: { lte: thirtyDaysFromNow },
|
||
},
|
||
select: {
|
||
resourceId: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
hoursPerDay: true,
|
||
},
|
||
});
|
||
|
||
// Build utilization map (simple: booked hours per day / available hours per day)
|
||
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);
|
||
|
||
// 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);
|
||
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) {
|
||
earliestAvailableDate = new Date(checkDate);
|
||
break;
|
||
}
|
||
}
|
||
checkDate.setDate(checkDate.getDate() + 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<string, number>();
|
||
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<string, number>();
|
||
const allSkillCounts = new Map<string, number>();
|
||
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<string, { skill: string; count: number; totalProficiency: number }>();
|
||
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,
|
||
};
|
||
}),
|
||
});
|