feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -21,6 +21,7 @@ describe("listAssignmentBookings", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -43,6 +44,7 @@ describe("listAssignmentBookings", () => {
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
dynamicFields: null,
},
resource: {
id: "res_2",
@@ -75,6 +77,7 @@ describe("listAssignmentBookings", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -97,6 +100,7 @@ describe("listAssignmentBookings", () => {
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
dynamicFields: null,
},
resource: {
id: "res_2",
@@ -133,7 +137,14 @@ describe("listAssignmentBookings", () => {
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -186,7 +197,14 @@ describe("listAssignmentBookings", () => {
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { commitDispoImportBatch } from "../index.js";
import { deriveTbdDispoProjectIdentity } from "../use-cases/dispo-import/tbd-projects.js";
function createCommitDb(overrides: Record<string, unknown> = {}) {
const tx = {
@@ -331,6 +332,162 @@ describe("commitDispoImportBatch", () => {
);
});
it("commits [tbd] rows as provisional projects when explicitly enabled", async () => {
const { db, tx } = createCommitDb();
const rawToken = "[DAI] 590 C AMG GT Stills [tbd]{CH} HB_";
const tbdProject = deriveTbdDispoProjectIdentity(rawToken, "Chg");
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_1",
recordType: "PROJECT",
message: `Planning token "${rawToken}" references [tbd] and requires project resolution`,
resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit",
normalizedData: { rawToken },
status: "UNRESOLVED",
},
]);
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "h.noerenberg",
displayName: "Hartmut Norenberg",
email: "h.noerenberg@accenture.com",
chapter: "Art Direction",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_tbd_1",
shortCode: tbdProject.shortCode,
projectKey: tbdProject.projectKey,
name: tbdProject.name,
clientCode: "DAI",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: true,
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
warnings: [],
rawPayload: {
rawTokens: [rawToken],
},
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_tbd_1",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-03T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-03T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
{
id: "sa_tbd_2",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-04T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
importTbdProjects: true,
});
expect(result).toEqual({
batchId: "batch_1",
counts: {
committedAssignments: 1,
committedProjects: 1,
committedResources: 1,
committedVacations: 0,
updatedEntitlements: 0,
updatedResourceAvailabilities: 0,
upsertedResourceRoles: 2,
},
unresolved: {
blocked: 0,
skippedTbd: 1,
},
});
expect(tx.project.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { shortCode: tbdProject.shortCode },
create: expect.objectContaining({
name: tbdProject.name,
shortCode: tbdProject.shortCode,
status: "DRAFT",
}),
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
endDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
}),
}),
);
expect(tx.stagedProject.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { importBatchId: "batch_1" },
}),
);
});
it("blocks commit when non-[tbd] unresolved rows remain", async () => {
const { db } = createCommitDb();
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
@@ -26,6 +27,7 @@ describe("dashboard use-cases", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -342,6 +344,175 @@ describe("dashboard use-cases", () => {
);
});
it("keeps proposed allocations out of actual chargeability by default but can include them", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const strict = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
const withProposed = await getDashboardChargeabilityOverview(db as never, {
includeProposed: true,
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(strict.top[0]?.actualChargeability).toBe(0);
expect(strict.top[0]?.expectedChargeability).toBe(5);
expect(withProposed.top[0]?.actualChargeability).toBe(5);
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("filters chargeability overview by departed state and country", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
countryId: "country_de",
departed: false,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const result = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
countryIds: ["country_de"],
departed: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
countryId: { in: ["country_de"] },
departed: false,
}),
}),
);
expect(result.top).toHaveLength(1);
expect(result.top[0]).toEqual(
expect.objectContaining({
id: "res_1",
countryId: "country_de",
departed: false,
}),
);
});
it("includes imported TBD draft projects in actual chargeability only when proposed work is enabled", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_tbd",
projectId: "proj_tbd",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_tbd",
name: "TBD: AMG",
shortCode: "TBD-AMG",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const strict = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
const withProposed = await getDashboardChargeabilityOverview(db as never, {
includeProposed: true,
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(strict.top[0]?.actualChargeability).toBe(0);
expect(strict.top[0]?.expectedChargeability).toBe(5);
expect(withProposed.top[0]?.actualChargeability).toBe(5);
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("returns distinct resource counts for chapter demand grouping", async () => {
const db = {
demandRequirement: {
@@ -640,6 +640,11 @@ describe("dispo import", () => {
isInternal: false,
utilizationCategoryCode: "Chg",
}),
expect.objectContaining({
isTbd: true,
isInternal: false,
shortCode: expect.stringMatching(/^TBD-/),
}),
]),
}),
);
+10
View File
@@ -22,6 +22,11 @@ export {
type AssignmentBookingWithFallback,
type ListAssignmentBookingsInput,
} from "./use-cases/allocation/list-assignment-bookings.js";
export {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
isImportedTbdDraftProject,
} from "./use-cases/allocation/chargeability-bookings.js";
export {
countPlanningEntries,
type CountPlanningEntriesInput,
@@ -93,6 +98,11 @@ export {
type EstimateListItem,
} from "./use-cases/estimate/index.js";
export {
recomputeResourceValueScores,
type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js";
export {
assessDispoImportReadiness,
parseMandatoryDispoReferenceWorkbook,
@@ -0,0 +1,53 @@
import type { Prisma } from "@planarchy/db";
import type { AssignmentBookingWithFallback } from "./list-assignment-bookings.js";
type ChargeabilityProjectLike = AssignmentBookingWithFallback["project"];
type ChargeabilityBookingLike = Pick<AssignmentBookingWithFallback, "status" | "project">;
function asObject(value: Prisma.JsonValue | null | undefined): Record<string, unknown> | null {
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function isImportedTbdDraftProject(project: ChargeabilityProjectLike): boolean {
if (project.status !== "DRAFT") {
return false;
}
const dynamicFields = asObject(project.dynamicFields);
const dispoImport = asObject(dynamicFields?.["dispoImport"] as Prisma.JsonValue | undefined);
return dispoImport?.["isTbd"] === true;
}
export function isChargeabilityRelevantProject(
project: ChargeabilityProjectLike,
includeProposed: boolean,
): boolean {
if (project.status === "ACTIVE") {
return true;
}
if (project.status === "CANCELLED") {
return false;
}
return includeProposed && isImportedTbdDraftProject(project);
}
export function isChargeabilityActualBooking(
booking: ChargeabilityBookingLike,
includeProposed: boolean,
): boolean {
if (!isChargeabilityRelevantProject(booking.project, includeProposed)) {
return false;
}
return (
booking.status === "CONFIRMED" ||
booking.status === "ACTIVE" ||
(includeProposed && booking.status === "PROPOSED")
);
}
@@ -25,6 +25,7 @@ export interface AssignmentBookingWithFallback {
shortCode: string;
status: string;
orderType: string;
dynamicFields: Prisma.JsonValue | null;
};
resource: {
id: string;
@@ -67,7 +68,14 @@ export async function listAssignmentBookings(
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -1,11 +1,18 @@
import type { PrismaClient } from "@planarchy/db";
import { computeChargeability } from "@planarchy/engine";
import type { WeekdayAvailability } from "@planarchy/shared";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
} from "../allocation/chargeability-bookings.js";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
export interface GetDashboardChargeabilityOverviewInput {
includeProposed?: boolean;
topN: number;
watchlistThreshold: number;
countryIds?: string[];
departed?: boolean;
now?: Date;
}
@@ -18,12 +25,20 @@ export async function getDashboardChargeabilityOverview(
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await db.resource.findMany({
where: { isActive: true },
where: {
isActive: true,
...(input.countryIds && input.countryIds.length > 0
? { countryId: { in: input.countryIds } }
: {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
countryId: true,
departed: true,
chargeabilityTarget: true,
availability: true,
},
@@ -37,11 +52,11 @@ export async function getDashboardChargeabilityOverview(
const stats = resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const actualAllocations = resourceBookings.filter(
(booking) =>
(booking.status === "CONFIRMED" || booking.status === "ACTIVE") &&
booking.project.status !== "DRAFT" &&
booking.project.status !== "CANCELLED",
const actualAllocations = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed === true),
);
const expectedAllocations = resourceBookings.filter(
(booking) => isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(
availability,
@@ -51,7 +66,7 @@ export async function getDashboardChargeabilityOverview(
);
const expected = computeChargeability(
availability,
resourceBookings,
expectedAllocations,
start,
end,
);
@@ -61,6 +76,8 @@ export async function getDashboardChargeabilityOverview(
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
countryId: resource.countryId,
departed: resource.departed,
chargeabilityTarget: resource.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
@@ -69,16 +86,14 @@ export async function getDashboardChargeabilityOverview(
return {
top: [...stats]
.sort((left, right) => right.actualChargeability - left.actualChargeability)
.slice(0, input.topN),
.sort((left, right) => right.actualChargeability - left.actualChargeability),
watchlist: [...stats]
.filter(
(resource) =>
resource.actualChargeability <
resource.chargeabilityTarget - input.watchlistThreshold,
)
.sort((left, right) => left.actualChargeability - right.actualChargeability)
.slice(0, input.topN),
.sort((left, right) => left.actualChargeability - right.actualChargeability),
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
};
}
@@ -573,10 +573,10 @@ export async function parseDispoPlanningWorkbook(
const existing = availabilityRules.get(key);
if (existing) {
const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}),
...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}),
...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}),
...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}),
});
existing.availableHours = nextAvailability.availableHours;
existing.percentage = nextAvailability.percentage;
@@ -585,10 +585,10 @@ export async function parseDispoPlanningWorkbook(
}
} else {
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}),
...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}),
...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}),
...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}),
});
availabilityRule.sourceRow = rowNumber;
availabilityRules.set(key, availabilityRule);
@@ -1,11 +1,4 @@
import XLSXModule from "xlsx";
const XLSX =
(
XLSXModule as typeof import("xlsx") & {
default?: typeof import("xlsx");
}
).default ?? (XLSXModule as typeof import("xlsx"));
import XLSX from "xlsx";
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
@@ -2,6 +2,11 @@ import type { Prisma } from "@planarchy/db";
import { AllocationType, DispoImportSourceKind, OrderType, StagedRecordStatus } from "@planarchy/db";
import { DISPO_INTERNAL_PROJECT_BUCKETS } from "@planarchy/shared";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import {
classifyDispoProject,
deriveResolvedDispoProjectIdentity,
deriveTbdDispoProjectIdentity,
} from "./tbd-projects.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
@@ -32,34 +37,6 @@ interface ResolvedStagedProject {
winProbability: number | null;
}
function extractBracketTokens(token: string): string[] {
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
}
function extractClientCode(token: string): string | null {
const candidates = extractBracketTokens(token).filter(
(entry) =>
entry.length > 0 &&
!entry.startsWith("_") &&
!/^\d+$/.test(entry) &&
entry.toLowerCase() !== "tbd",
);
return candidates[0] ?? null;
}
function deriveProjectName(token: string, fallbackProjectKey: string): string {
const normalized = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return normalized.length > 0 ? normalized : `Project ${fallbackProjectKey}`;
}
function updateDateRange(project: ResolvedStagedProject, assignmentDate: Date) {
if (assignmentDate < project.startDate) {
project.startDate = assignmentDate;
@@ -131,7 +108,7 @@ export async function stageDispoProjects(
continue;
}
if (assignment.isTbd || assignment.isUnassigned) {
if (assignment.isUnassigned) {
continue;
}
@@ -153,32 +130,27 @@ export async function stageDispoProjects(
continue;
}
if (!assignment.projectKey) {
if (!assignment.projectKey && !assignment.isTbd) {
continue;
}
const derivedClientCode = extractClientCode(assignment.rawToken);
const derivedName = deriveProjectName(assignment.rawToken, assignment.projectKey);
const shortCode = assignment.projectKey;
const existing = projects.get(assignment.projectKey);
const identity = assignment.isTbd
? deriveTbdDispoProjectIdentity(assignment.rawToken, assignment.utilizationCategoryCode)
: deriveResolvedDispoProjectIdentity(assignment.rawToken, assignment.projectKey!);
const classification = classifyDispoProject(assignment.utilizationCategoryCode);
const existing = projects.get(identity.projectKey);
if (!existing) {
projects.set(assignment.projectKey, {
allocationType: assignment.utilizationCategoryCode === "Chg"
? AllocationType.EXT
: AllocationType.INT,
clientCode: derivedClientCode,
projects.set(identity.projectKey, {
allocationType: classification.allocationType,
clientCode: identity.clientCode,
isInternal: false,
isTbd: false,
name: derivedName,
orderType: assignment.utilizationCategoryCode === "Chg"
? OrderType.CHARGEABLE
: assignment.utilizationCategoryCode === "BD"
? OrderType.BD
: OrderType.INTERNAL,
projectKey: assignment.projectKey,
isTbd: assignment.isTbd,
name: identity.name,
orderType: classification.orderType,
projectKey: identity.projectKey,
rawTokens: new Set<string>([assignment.rawToken]),
shortCode,
shortCode: identity.shortCode,
sourceColumn: assignment.sourceColumn,
sourceRow: assignment.sourceRow,
startDate: assignment.assignmentDate,
@@ -193,19 +165,19 @@ export async function stageDispoProjects(
updateDateRange(existing, assignment.assignmentDate);
existing.rawTokens.add(assignment.rawToken);
if (derivedClientCode && existing.clientCode && existing.clientCode !== derivedClientCode) {
if (identity.clientCode && existing.clientCode && existing.clientCode !== identity.clientCode) {
existing.warnings.add(
`Conflicting client codes for project ${assignment.projectKey}: ${existing.clientCode} vs ${derivedClientCode}`,
`Conflicting client codes for project ${identity.projectKey}: ${existing.clientCode} vs ${identity.clientCode}`,
);
}
if (!existing.clientCode && derivedClientCode) {
existing.clientCode = derivedClientCode;
if (!existing.clientCode && identity.clientCode) {
existing.clientCode = identity.clientCode;
}
if (normalizeText(existing.name) !== normalizeText(derivedName)) {
if (normalizeText(existing.name) !== normalizeText(identity.name)) {
existing.warnings.add(
`Multiple project names observed for ${assignment.projectKey}; using "${existing.name}"`,
`Multiple project names observed for ${identity.projectKey}; using "${existing.name}"`,
);
}
@@ -215,7 +187,7 @@ export async function stageDispoProjects(
existing.utilizationCategoryCode !== assignment.utilizationCategoryCode
) {
existing.warnings.add(
`Conflicting utilization categories for ${assignment.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
`Conflicting utilization categories for ${identity.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
);
}
@@ -229,7 +201,7 @@ export async function stageDispoProjects(
existing.winProbability !== assignment.winProbability
) {
existing.warnings.add(
`Conflicting win probabilities for ${assignment.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
`Conflicting win probabilities for ${identity.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
);
}
@@ -0,0 +1,116 @@
import { createHash } from "node:crypto";
import { AllocationType, OrderType } from "@planarchy/db";
function extractBracketTokens(token: string): string[] {
return Array.from(
token.matchAll(/\[([^\]]+)\]/g),
(match) => match[1]?.trim() ?? "",
).filter(Boolean);
}
function extractClientCode(token: string): string | null {
const candidates = extractBracketTokens(token).filter(
(entry) =>
entry.length > 0 &&
!entry.startsWith("_") &&
!/^\d+$/.test(entry) &&
entry.toLowerCase() !== "tbd",
);
return candidates[0] ?? null;
}
function extractProjectLabel(token: string): string | null {
const stripped = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return stripped.length > 0 ? stripped : null;
}
function slugifyFragment(value: string): string {
return value
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function shortSegment(value: string | null | undefined, fallback: string, maxLength: number): string {
const normalized = slugifyFragment(value ?? "");
return (normalized.length > 0 ? normalized : fallback).slice(0, maxLength);
}
export interface DerivedDispoProjectIdentity {
clientCode: string | null;
name: string;
projectKey: string;
shortCode: string;
}
export interface DerivedDispoProjectClassification {
allocationType: AllocationType;
orderType: OrderType;
}
export function classifyDispoProject(utilizationCategoryCode: string | null): DerivedDispoProjectClassification {
if (utilizationCategoryCode === "Chg") {
return {
allocationType: AllocationType.EXT,
orderType: OrderType.CHARGEABLE,
};
}
if (utilizationCategoryCode === "BD") {
return {
allocationType: AllocationType.INT,
orderType: OrderType.BD,
};
}
return {
allocationType: AllocationType.INT,
orderType: OrderType.INTERNAL,
};
}
export function deriveResolvedDispoProjectIdentity(
rawToken: string,
fallbackProjectKey: string,
): DerivedDispoProjectIdentity {
const name = extractProjectLabel(rawToken) ?? `Project ${fallbackProjectKey}`;
return {
clientCode: extractClientCode(rawToken),
name,
projectKey: fallbackProjectKey,
shortCode: fallbackProjectKey,
};
}
export function deriveTbdDispoProjectIdentity(
rawToken: string,
utilizationCategoryCode: string | null,
): DerivedDispoProjectIdentity {
const clientCode = extractClientCode(rawToken);
const name = extractProjectLabel(rawToken) ?? "Unresolved Dispo Work";
const clientSegment = shortSegment(clientCode, "GEN", 10);
const utilizationSegment = shortSegment(utilizationCategoryCode, "UNK", 6);
const labelSegment = shortSegment(name, "UNRESOLVED-WORK", 24);
const hash = createHash("sha1")
.update(`${rawToken.trim().toLowerCase()}|${utilizationCategoryCode ?? ""}`)
.digest("hex")
.slice(0, 8)
.toUpperCase();
const shortCode = `TBD-${clientSegment}-${utilizationSegment}-${labelSegment}-${hash}`;
return {
clientCode,
name: `TBD: ${name}`,
projectKey: shortCode,
shortCode,
};
}
@@ -0,0 +1,4 @@
export {
recomputeResourceValueScores,
type RecomputeResourceValueScoresInput,
} from "./recompute-resource-value-scores.js";
@@ -0,0 +1,111 @@
import type { PrismaClient } from "@planarchy/db";
import { computeValueScore } from "@planarchy/staffing";
import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings">> &
Pick<PrismaClient, "assignment" | "resource" | "$transaction">;
export interface RecomputeResourceValueScoresInput {
daysBack?: number;
}
export async function recomputeResourceValueScores(
db: ResourceValueScoreDbClient,
input: RecomputeResourceValueScoresInput = {},
) {
if (
typeof db.resource?.findMany !== "function" ||
typeof db.resource?.update !== "function"
) {
return { updated: 0 };
}
const daysBack = input.daysBack ?? 90;
const [resources, settings] = await Promise.all([
db.resource.findMany({
where: { isActive: true },
select: {
id: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
},
}),
db.systemSettings?.findUnique?.({ where: { id: "singleton" } }) ?? Promise.resolve(null),
]);
if (resources.length === 0) {
return { updated: 0 };
}
const bookings = await listAssignmentBookings(db, {
startDate: new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000),
endDate: new Date(),
resourceIds: resources.map((resource) => resource.id),
});
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
const weights =
(settings?.scoreWeights as unknown as typeof defaultWeights | null) ?? defaultWeights;
const maxLcrCents = resources.reduce((max, resource) => Math.max(max, resource.lcrCents), 0);
const now = new Date();
type SkillRow = {
category?: string;
isMainSkill?: boolean;
proficiency: number;
skill: string;
yearsExperience?: number;
};
const totalWorkDays = daysBack * (5 / 7);
const availableHours = totalWorkDays * 8;
const updates = resources.map((resource) => {
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const bookedHours = resourceBookings.reduce((sum, booking) => {
const days = Math.max(
0,
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1,
);
return sum + booking.hoursPerDay * days;
}, 0);
const currentChargeability =
availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const breakdown = computeValueScore(
{
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
maxLcrCents,
},
weights,
);
return db.resource.update({
where: { id: resource.id },
data: {
valueScore: breakdown.total,
valueScoreBreakdown:
breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
valueScoreUpdatedAt: now,
},
});
});
await db.$transaction(updates);
return { updated: updates.length };
}