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:
@@ -14,6 +14,7 @@
|
||||
"@planarchy/db": "workspace:*",
|
||||
"@planarchy/engine": "workspace:*",
|
||||
"@planarchy/shared": "workspace:*",
|
||||
"@planarchy/staffing": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
|
||||
@@ -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-/),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user