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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user