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
@@ -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,
};
}