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-/),
}),
]),
}),
);