chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@planarchy/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./schemas": "./src/schemas/index.ts",
|
||||
"./constants": "./src/constants/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@planarchy/tsconfig": "workspace:*",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_LAYOUT_VERSION,
|
||||
createDefaultDashboardLayout,
|
||||
createDashboardWidget,
|
||||
getNextDashboardWidgetY,
|
||||
normalizeDashboardLayout,
|
||||
} from "../index.js";
|
||||
|
||||
describe("dashboard layout normalization", () => {
|
||||
it("returns the default layout for non-object input", () => {
|
||||
expect(normalizeDashboardLayout(null)).toEqual(createDefaultDashboardLayout());
|
||||
});
|
||||
|
||||
it("repairs invalid persisted widget coordinates and normalizes config", () => {
|
||||
const layout = normalizeDashboardLayout({
|
||||
version: 1,
|
||||
gridCols: 12,
|
||||
widgets: [
|
||||
{
|
||||
id: "alpha",
|
||||
type: "stat-cards",
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 12,
|
||||
h: 3,
|
||||
minW: 6,
|
||||
minH: 2,
|
||||
config: { ignored: true },
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
type: "peak-times-chart",
|
||||
x: 0,
|
||||
y: null,
|
||||
w: 8,
|
||||
h: 5,
|
||||
minW: 4,
|
||||
minH: 4,
|
||||
config: { granularity: "week", groupBy: "chapter", extra: "drop-me" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(layout.version).toBe(DASHBOARD_LAYOUT_VERSION);
|
||||
expect(layout.widgets).toHaveLength(2);
|
||||
expect(layout.widgets[0]?.config).toEqual({});
|
||||
expect(layout.widgets[1]?.y).toBe(3);
|
||||
expect(layout.widgets[1]?.config).toEqual({
|
||||
granularity: "week",
|
||||
groupBy: "chapter",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops unknown widgets and deduplicates ids", () => {
|
||||
const layout = normalizeDashboardLayout({
|
||||
gridCols: 12,
|
||||
widgets: [
|
||||
createDashboardWidget("resource-table", { id: "duplicate", y: 0 }),
|
||||
createDashboardWidget("project-table", { id: "duplicate", y: 6 }),
|
||||
{ id: "bad", type: "not-real", x: 0, y: 0, w: 1, h: 1, config: {} },
|
||||
],
|
||||
});
|
||||
|
||||
expect(layout.widgets).toHaveLength(2);
|
||||
expect(layout.widgets[0]?.id).toBe("duplicate");
|
||||
expect(layout.widgets[1]?.id).toBe("duplicate-2");
|
||||
});
|
||||
|
||||
it("computes the next widget row from current widget bottoms", () => {
|
||||
const widgets = [
|
||||
createDashboardWidget("stat-cards", { id: "one", y: 0, h: 3 }),
|
||||
createDashboardWidget("demand-view", { id: "two", y: 4, h: 5 }),
|
||||
];
|
||||
|
||||
expect(getNextDashboardWidgetY(widgets)).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DISPO_INTERNAL_PROJECT_BUCKETS,
|
||||
DISPO_REQUIRED_ROLE_SEEDS,
|
||||
createWeekdayAvailabilityFromFte,
|
||||
normalizeDispoChapterToken,
|
||||
normalizeDispoRoleToken,
|
||||
normalizeDispoUtilizationToken,
|
||||
resolveCanonicalResourceIdentity,
|
||||
} from "../index.js";
|
||||
|
||||
describe("dispo import normalization", () => {
|
||||
it("normalizes canonical identity and detects enterprise/eid conflicts", () => {
|
||||
expect(resolveCanonicalResourceIdentity(" Alice.Example ", "alice.example")).toEqual({
|
||||
canonicalId: "alice.example",
|
||||
normalizedEnterpriseId: "alice.example",
|
||||
normalizedEid: "alice.example",
|
||||
conflict: false,
|
||||
});
|
||||
|
||||
expect(resolveCanonicalResourceIdentity("alice.example", "bob.example")).toEqual({
|
||||
canonicalId: null,
|
||||
normalizedEnterpriseId: "alice.example",
|
||||
normalizedEid: "bob.example",
|
||||
conflict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps chapter, role, and utilization tokens to canonical values", () => {
|
||||
expect(normalizeDispoChapterToken("3d")).toBe("Digital Content Production");
|
||||
expect(normalizeDispoRoleToken(" pm ")).toBe("Project Manager");
|
||||
expect(normalizeDispoUtilizationToken("md")).toBe("MD&I");
|
||||
expect(normalizeDispoUtilizationToken("un")).toBeNull();
|
||||
expect(normalizeDispoRoleToken("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates the default weekday fallback availability from fte", () => {
|
||||
expect(createWeekdayAvailabilityFromFte(1)).toEqual({
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
});
|
||||
|
||||
expect(createWeekdayAvailabilityFromFte(0.5)).toEqual({
|
||||
monday: 4,
|
||||
tuesday: 4,
|
||||
wednesday: 4,
|
||||
thursday: 4,
|
||||
friday: 4,
|
||||
});
|
||||
|
||||
expect(createWeekdayAvailabilityFromFte(0.8)).toEqual({
|
||||
monday: 6.4,
|
||||
tuesday: 6.4,
|
||||
wednesday: 6.4,
|
||||
thursday: 6.4,
|
||||
friday: 6.4,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps canonical bucket and role seed definitions stable", () => {
|
||||
expect(DISPO_INTERNAL_PROJECT_BUCKETS).toEqual([
|
||||
{
|
||||
sourceToken: "MO",
|
||||
shortCode: "INT-MO",
|
||||
name: "Management & Operations",
|
||||
utilizationCategoryCode: "M&O",
|
||||
},
|
||||
{
|
||||
sourceToken: "MD",
|
||||
shortCode: "INT-MD",
|
||||
name: "Market Development & Initiatives",
|
||||
utilizationCategoryCode: "MD&I",
|
||||
},
|
||||
{
|
||||
sourceToken: "PD",
|
||||
shortCode: "INT-PD",
|
||||
name: "People Development & Recruitment",
|
||||
utilizationCategoryCode: "PD&R",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(DISPO_REQUIRED_ROLE_SEEDS.map((role) => role.name)).toEqual([
|
||||
"2D Artist",
|
||||
"3D Artist",
|
||||
"Project Manager",
|
||||
"Art Director",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CreateRateCardSchema,
|
||||
CreateRateCardLineSchema,
|
||||
UpdateRateCardSchema,
|
||||
} from "../schemas/rate-card.schema.js";
|
||||
import {
|
||||
CreateAllocationBaseSchema,
|
||||
CreateDemandRequirementBaseSchema,
|
||||
CreateAssignmentBaseSchema,
|
||||
CreateAllocationSchema,
|
||||
CreateDemandRequirementSchema,
|
||||
CreateAssignmentSchema,
|
||||
FillDemandRequirementSchema,
|
||||
ShiftProjectSchema,
|
||||
UpdateAllocationHoursSchema,
|
||||
} from "../schemas/allocation.schema.js";
|
||||
import {
|
||||
CreateEstimateSchema,
|
||||
EstimateDemandLineSchema,
|
||||
ScopeItemSchema,
|
||||
EstimateAssumptionSchema,
|
||||
ResourceCostSnapshotSchema,
|
||||
EstimateMetricSchema,
|
||||
EstimateExportSummarySchema,
|
||||
} from "../schemas/estimate.schema.js";
|
||||
import { AllocationStatus, EstimateStatus, EstimateVersionStatus, EstimateExportFormat } from "../types/enums.js";
|
||||
|
||||
// ─── Rate Card Schemas ──────────────────────────────────────────────────────
|
||||
|
||||
describe("CreateRateCardSchema", () => {
|
||||
it("accepts valid input with defaults", () => {
|
||||
const result = CreateRateCardSchema.parse({ name: "Standard Rates" });
|
||||
expect(result.name).toBe("Standard Rates");
|
||||
expect(result.currency).toBe("EUR");
|
||||
expect(result.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts full input with lines and clientId", () => {
|
||||
const result = CreateRateCardSchema.parse({
|
||||
name: "Client Rate Card",
|
||||
currency: "USD",
|
||||
clientId: "client-1",
|
||||
effectiveFrom: "2026-01-01",
|
||||
effectiveTo: "2026-12-31",
|
||||
source: "Import",
|
||||
lines: [{ costRateCents: 5000 }],
|
||||
});
|
||||
expect(result.clientId).toBe("client-1");
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.effectiveFrom).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
expect(() => CreateRateCardSchema.parse({ name: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid currency length", () => {
|
||||
expect(() =>
|
||||
CreateRateCardSchema.parse({ name: "Test", currency: "EURO" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateRateCardLineSchema", () => {
|
||||
it("accepts minimal input", () => {
|
||||
const result = CreateRateCardLineSchema.parse({ costRateCents: 8000 });
|
||||
expect(result.costRateCents).toBe(8000);
|
||||
expect(result.attributes).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts full input with all optional fields", () => {
|
||||
const result = CreateRateCardLineSchema.parse({
|
||||
roleId: "role-1",
|
||||
chapter: "3D",
|
||||
location: "Munich",
|
||||
seniority: "Senior",
|
||||
workType: "Production",
|
||||
serviceGroup: "CGI",
|
||||
costRateCents: 8000,
|
||||
billRateCents: 12000,
|
||||
machineRateCents: 500,
|
||||
attributes: { tier: "A" },
|
||||
});
|
||||
expect(result.chapter).toBe("3D");
|
||||
expect(result.billRateCents).toBe(12000);
|
||||
});
|
||||
|
||||
it("rejects negative cost rate", () => {
|
||||
expect(() => CreateRateCardLineSchema.parse({ costRateCents: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer cost rate", () => {
|
||||
expect(() => CreateRateCardLineSchema.parse({ costRateCents: 100.5 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateRateCardSchema", () => {
|
||||
it("accepts partial updates", () => {
|
||||
const result = UpdateRateCardSchema.parse({ name: "Updated" });
|
||||
expect(result.name).toBe("Updated");
|
||||
expect(result.currency).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts nullable clientId", () => {
|
||||
const result = UpdateRateCardSchema.parse({ clientId: null });
|
||||
expect(result.clientId).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts nullable effectiveFrom", () => {
|
||||
const result = UpdateRateCardSchema.parse({ effectiveFrom: null });
|
||||
expect(result.effectiveFrom).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Allocation Schemas ─────────────────────────────────────────────────────
|
||||
|
||||
describe("CreateDemandRequirementSchema", () => {
|
||||
const validDemand = {
|
||||
projectId: "proj-1",
|
||||
startDate: "2026-03-01",
|
||||
endDate: "2026-03-31",
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
};
|
||||
|
||||
it("accepts valid demand requirement", () => {
|
||||
const result = CreateDemandRequirementSchema.parse(validDemand);
|
||||
expect(result.projectId).toBe("proj-1");
|
||||
expect(result.headcount).toBe(1);
|
||||
expect(result.status).toBe(AllocationStatus.PROPOSED);
|
||||
expect(result.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects end date before start date", () => {
|
||||
expect(() =>
|
||||
CreateDemandRequirementSchema.parse({
|
||||
...validDemand,
|
||||
startDate: "2026-03-31",
|
||||
endDate: "2026-03-01",
|
||||
}),
|
||||
).toThrow("End date must be after start date");
|
||||
});
|
||||
|
||||
it("rejects hoursPerDay > 24", () => {
|
||||
expect(() =>
|
||||
CreateDemandRequirementBaseSchema.parse({ ...validDemand, hoursPerDay: 25 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects percentage > 100", () => {
|
||||
expect(() =>
|
||||
CreateDemandRequirementBaseSchema.parse({ ...validDemand, percentage: 101 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateAssignmentSchema", () => {
|
||||
const validAssignment = {
|
||||
resourceId: "res-1",
|
||||
projectId: "proj-1",
|
||||
startDate: "2026-03-01",
|
||||
endDate: "2026-03-31",
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
};
|
||||
|
||||
it("accepts valid assignment", () => {
|
||||
const result = CreateAssignmentSchema.parse(validAssignment);
|
||||
expect(result.resourceId).toBe("res-1");
|
||||
expect(result.status).toBe(AllocationStatus.PROPOSED);
|
||||
});
|
||||
|
||||
it("requires resourceId", () => {
|
||||
const { resourceId: _, ...withoutResource } = validAssignment;
|
||||
expect(() => CreateAssignmentBaseSchema.parse(withoutResource)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects end date before start date", () => {
|
||||
expect(() =>
|
||||
CreateAssignmentSchema.parse({
|
||||
...validAssignment,
|
||||
startDate: "2026-03-31",
|
||||
endDate: "2026-03-01",
|
||||
}),
|
||||
).toThrow("End date must be after start date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FillDemandRequirementSchema", () => {
|
||||
it("accepts valid fill input", () => {
|
||||
const result = FillDemandRequirementSchema.parse({
|
||||
demandRequirementId: "demand-1",
|
||||
resourceId: "res-1",
|
||||
});
|
||||
expect(result.demandRequirementId).toBe("demand-1");
|
||||
expect(result.hoursPerDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects hoursPerDay below 0.5", () => {
|
||||
expect(() =>
|
||||
FillDemandRequirementSchema.parse({
|
||||
demandRequirementId: "d-1",
|
||||
resourceId: "r-1",
|
||||
hoursPerDay: 0.3,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShiftProjectSchema", () => {
|
||||
it("accepts valid shift", () => {
|
||||
const result = ShiftProjectSchema.parse({
|
||||
projectId: "proj-1",
|
||||
newStartDate: "2026-04-01",
|
||||
newEndDate: "2026-06-30",
|
||||
});
|
||||
expect(result.projectId).toBe("proj-1");
|
||||
expect(result.newStartDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("rejects end before start", () => {
|
||||
expect(() =>
|
||||
ShiftProjectSchema.parse({
|
||||
projectId: "proj-1",
|
||||
newStartDate: "2026-06-30",
|
||||
newEndDate: "2026-04-01",
|
||||
}),
|
||||
).toThrow("New end date must be after new start date");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Estimate Schemas ───────────────────────────────────────────────────────
|
||||
|
||||
describe("CreateEstimateSchema", () => {
|
||||
it("accepts minimal input", () => {
|
||||
const result = CreateEstimateSchema.parse({ name: "Q2 Estimate" });
|
||||
expect(result.name).toBe("Q2 Estimate");
|
||||
expect(result.baseCurrency).toBe("EUR");
|
||||
expect(result.status).toBe(EstimateStatus.DRAFT);
|
||||
expect(result.assumptions).toEqual([]);
|
||||
expect(result.scopeItems).toEqual([]);
|
||||
expect(result.demandLines).toEqual([]);
|
||||
expect(result.resourceSnapshots).toEqual([]);
|
||||
expect(result.metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
expect(() => CreateEstimateSchema.parse({ name: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EstimateDemandLineSchema", () => {
|
||||
it("accepts minimal demand line", () => {
|
||||
const result = EstimateDemandLineSchema.parse({
|
||||
name: "3D Artist",
|
||||
hours: 160,
|
||||
});
|
||||
expect(result.name).toBe("3D Artist");
|
||||
expect(result.hours).toBe(160);
|
||||
expect(result.lineType).toBe("LABOR");
|
||||
expect(result.currency).toBe("EUR");
|
||||
expect(result.costRateCents).toBe(0);
|
||||
expect(result.monthlySpread).toEqual({});
|
||||
expect(result.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts full demand line with metadata", () => {
|
||||
const result = EstimateDemandLineSchema.parse({
|
||||
name: "Senior 3D Artist",
|
||||
hours: 320,
|
||||
days: 40,
|
||||
fte: 1,
|
||||
roleId: "role-1",
|
||||
resourceId: "res-1",
|
||||
chapter: "3D Visualization",
|
||||
costRateCents: 8000,
|
||||
billRateCents: 12000,
|
||||
costTotalCents: 256_000_0,
|
||||
priceTotalCents: 384_000_0,
|
||||
monthlySpread: { "2026-03": 80, "2026-04": 80 },
|
||||
metadata: {
|
||||
calculation: {
|
||||
costRateMode: "resource",
|
||||
billRateMode: "manual",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.chapter).toBe("3D Visualization");
|
||||
expect(result.monthlySpread["2026-03"]).toBe(80);
|
||||
expect(result.metadata.calculation?.costRateMode).toBe("resource");
|
||||
});
|
||||
|
||||
it("rejects negative hours", () => {
|
||||
expect(() =>
|
||||
EstimateDemandLineSchema.parse({ name: "Test", hours: -1 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScopeItemSchema", () => {
|
||||
it("accepts valid scope item", () => {
|
||||
const result = ScopeItemSchema.parse({
|
||||
sequenceNo: 1,
|
||||
scopeType: "shot",
|
||||
name: "Hero shot exterior",
|
||||
});
|
||||
expect(result.sequenceNo).toBe(1);
|
||||
expect(result.technicalSpec).toEqual({});
|
||||
expect(result.metadata).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects negative sequenceNo", () => {
|
||||
expect(() =>
|
||||
ScopeItemSchema.parse({ sequenceNo: -1, scopeType: "shot", name: "Test" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceCostSnapshotSchema", () => {
|
||||
it("accepts valid snapshot", () => {
|
||||
const result = ResourceCostSnapshotSchema.parse({
|
||||
displayName: "John Doe",
|
||||
lcrCents: 6000,
|
||||
ucrCents: 4000,
|
||||
});
|
||||
expect(result.displayName).toBe("John Doe");
|
||||
expect(result.currency).toBe("EUR");
|
||||
});
|
||||
|
||||
it("rejects negative LCR", () => {
|
||||
expect(() =>
|
||||
ResourceCostSnapshotSchema.parse({
|
||||
displayName: "Test",
|
||||
lcrCents: -100,
|
||||
ucrCents: 0,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EstimateExportSummarySchema", () => {
|
||||
it("accepts valid export summary", () => {
|
||||
const result = EstimateExportSummarySchema.parse({
|
||||
estimateId: "est-1",
|
||||
estimateName: "Test Estimate",
|
||||
versionId: "ver-1",
|
||||
versionNumber: 1,
|
||||
versionStatus: EstimateVersionStatus.APPROVED,
|
||||
baseCurrency: "EUR",
|
||||
assumptionCount: 5,
|
||||
scopeItemCount: 10,
|
||||
demandLineCount: 8,
|
||||
resourceSnapshotCount: 3,
|
||||
totalHours: 1200,
|
||||
totalCostCents: 600_000_00,
|
||||
totalPriceCents: 900_000_00,
|
||||
marginCents: 300_000_00,
|
||||
marginPercent: 33.33,
|
||||
});
|
||||
expect(result.marginPercent).toBeCloseTo(33.33);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ColumnDef } from "../types/columns.js";
|
||||
|
||||
export const RESOURCE_COLUMNS: ColumnDef[] = [
|
||||
{ key: "displayName", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "eid", label: "EID", defaultVisible: true, hideable: true },
|
||||
{ key: "chapter", label: "Chapter", defaultVisible: true, hideable: true },
|
||||
{ key: "roles", label: "Roles", defaultVisible: true, hideable: true },
|
||||
{ key: "chargeability", label: "Chargeability", defaultVisible: true, hideable: true, sortable: true },
|
||||
{ key: "lcr", label: "LCR", defaultVisible: false, hideable: true },
|
||||
{ key: "valueScore", label: "Score", defaultVisible: false, hideable: true },
|
||||
{ key: "isActive", label: "Status", defaultVisible: true, hideable: true },
|
||||
];
|
||||
|
||||
export const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "shortCode", label: "Code", defaultVisible: true, hideable: false },
|
||||
{ key: "name", label: "Name", defaultVisible: true, hideable: false },
|
||||
{ key: "status", label: "Status", defaultVisible: true, hideable: true },
|
||||
{ key: "orderType", label: "Type", defaultVisible: true, hideable: true },
|
||||
{ key: "dates", label: "Dates", defaultVisible: true, hideable: true },
|
||||
{ key: "budget", label: "Budget", defaultVisible: false, hideable: true },
|
||||
{ key: "allocations", label: "Allocations", defaultVisible: true, hideable: true },
|
||||
{ key: "responsible", label: "Responsible", defaultVisible: false, hideable: true },
|
||||
];
|
||||
|
||||
export const ALLOCATION_COLUMNS: ColumnDef[] = [
|
||||
{ key: "resource", label: "Resource", defaultVisible: true, hideable: false },
|
||||
{ key: "project", label: "Project", defaultVisible: true, hideable: false },
|
||||
{ key: "dates", label: "Dates", defaultVisible: true, hideable: true },
|
||||
{ key: "hoursPerDay", label: "h/day", defaultVisible: true, hideable: true },
|
||||
{ key: "status", label: "Status", defaultVisible: true, hideable: true },
|
||||
{ key: "cost", label: "Daily Cost", defaultVisible: false, hideable: true },
|
||||
{ key: "role", label: "Role", defaultVisible: true, hideable: true },
|
||||
];
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { WeekdayAvailability } from "../types/resource.js";
|
||||
|
||||
export const DISPO_RESOURCE_CHAPTER_BY_TOKEN = {
|
||||
"2D": "Digital Content Production",
|
||||
"3D": "Digital Content Production",
|
||||
PM: "Project Management",
|
||||
AD: "Art Direction",
|
||||
} as const;
|
||||
|
||||
export const DISPO_ASSIGNMENT_ROLE_BY_TOKEN = {
|
||||
"2D": "2D Artist",
|
||||
"3D": "3D Artist",
|
||||
PM: "Project Manager",
|
||||
AD: "Art Director",
|
||||
} as const;
|
||||
|
||||
export const DISPO_UTILIZATION_CODE_BY_TOKEN = {
|
||||
CH: "Chg",
|
||||
MO: "M&O",
|
||||
MD: "MD&I",
|
||||
PD: "PD&R",
|
||||
AB: "Absence",
|
||||
NA: "Absence",
|
||||
UN: null,
|
||||
} as const;
|
||||
|
||||
export const DISPO_UTILIZATION_CATEGORIES = [
|
||||
{
|
||||
code: "Chg",
|
||||
name: "Chargeable",
|
||||
description: "Billable client project work",
|
||||
sortOrder: 1,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
code: "BD",
|
||||
name: "Business Development",
|
||||
description: "Sales, proposals, presales activities",
|
||||
sortOrder: 2,
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
code: "MD&I",
|
||||
name: "Market Development & Initiatives",
|
||||
description: "R&D, innovation, market development",
|
||||
sortOrder: 3,
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
code: "M&O",
|
||||
name: "Management & Operations",
|
||||
description: "Internal admin, management overhead",
|
||||
sortOrder: 4,
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
code: "PD&R",
|
||||
name: "People Development & Recruitment",
|
||||
description: "Training, hiring, onboarding",
|
||||
sortOrder: 5,
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
code: "Absence",
|
||||
name: "Absence & Non Standard",
|
||||
description: "Vacation, illness, non-standard leave (reduces SAH)",
|
||||
sortOrder: 6,
|
||||
isDefault: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DISPO_INTERNAL_PROJECT_BUCKETS = [
|
||||
{
|
||||
sourceToken: "MO",
|
||||
shortCode: "INT-MO",
|
||||
name: "Management & Operations",
|
||||
utilizationCategoryCode: "M&O",
|
||||
},
|
||||
{
|
||||
sourceToken: "MD",
|
||||
shortCode: "INT-MD",
|
||||
name: "Market Development & Initiatives",
|
||||
utilizationCategoryCode: "MD&I",
|
||||
},
|
||||
{
|
||||
sourceToken: "PD",
|
||||
shortCode: "INT-PD",
|
||||
name: "People Development & Recruitment",
|
||||
utilizationCategoryCode: "PD&R",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DISPO_REQUIRED_ROLE_SEEDS = [
|
||||
{
|
||||
name: "2D Artist",
|
||||
description: "Canonical Dispo assignment role for 2D delivery work",
|
||||
},
|
||||
{
|
||||
name: "3D Artist",
|
||||
description: "Canonical Dispo assignment role for 3D delivery work",
|
||||
},
|
||||
{
|
||||
name: "Project Manager",
|
||||
description: "Canonical Dispo assignment role for project management delivery",
|
||||
},
|
||||
{
|
||||
name: "Art Director",
|
||||
description: "Canonical Dispo assignment role for art direction delivery",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type DispoRoleToken = keyof typeof DISPO_ASSIGNMENT_ROLE_BY_TOKEN;
|
||||
export type DispoChapterToken = keyof typeof DISPO_RESOURCE_CHAPTER_BY_TOKEN;
|
||||
export type DispoUtilizationToken = keyof typeof DISPO_UTILIZATION_CODE_BY_TOKEN;
|
||||
export type DispoInternalBucketDefinition = (typeof DISPO_INTERNAL_PROJECT_BUCKETS)[number];
|
||||
|
||||
function normalizeDispoTokenValue(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ").toUpperCase();
|
||||
}
|
||||
|
||||
function roundToTwoDecimals(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
export function normalizeCanonicalResourceIdentity(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveCanonicalResourceIdentity(enterpriseId?: string | null, eid?: string | null) {
|
||||
const normalizedEnterpriseId = enterpriseId ? normalizeCanonicalResourceIdentity(enterpriseId) : null;
|
||||
const normalizedEid = eid ? normalizeCanonicalResourceIdentity(eid) : null;
|
||||
const conflict = Boolean(
|
||||
normalizedEnterpriseId &&
|
||||
normalizedEid &&
|
||||
normalizedEnterpriseId !== normalizedEid,
|
||||
);
|
||||
|
||||
return {
|
||||
canonicalId: conflict ? null : normalizedEnterpriseId ?? normalizedEid,
|
||||
normalizedEnterpriseId,
|
||||
normalizedEid,
|
||||
conflict,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDispoChapterToken(token?: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const normalizedToken = normalizeDispoTokenValue(token) as DispoChapterToken;
|
||||
return DISPO_RESOURCE_CHAPTER_BY_TOKEN[normalizedToken] ?? null;
|
||||
}
|
||||
|
||||
export function normalizeDispoRoleToken(token?: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const normalizedToken = normalizeDispoTokenValue(token) as DispoRoleToken;
|
||||
return DISPO_ASSIGNMENT_ROLE_BY_TOKEN[normalizedToken] ?? null;
|
||||
}
|
||||
|
||||
export function normalizeDispoUtilizationToken(token?: string | null): string | null {
|
||||
if (!token) return null;
|
||||
const normalizedToken = normalizeDispoTokenValue(token) as DispoUtilizationToken;
|
||||
return DISPO_UTILIZATION_CODE_BY_TOKEN[normalizedToken] ?? null;
|
||||
}
|
||||
|
||||
export function createWeekdayAvailabilityFromFte(
|
||||
fte: number,
|
||||
dailyWorkingHours = 8,
|
||||
): WeekdayAvailability {
|
||||
const safeFte = Math.min(Math.max(fte, 0), 1);
|
||||
const hoursPerDay = roundToTwoDecimals(dailyWorkingHours * safeFte);
|
||||
|
||||
return {
|
||||
monday: hoursPerDay,
|
||||
tuesday: hoursPerDay,
|
||||
wednesday: hoursPerDay,
|
||||
thursday: hoursPerDay,
|
||||
friday: hoursPerDay,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* German federal states (Bundesländer) with abbreviations.
|
||||
*/
|
||||
export const GERMAN_FEDERAL_STATES: Record<string, string> = {
|
||||
BB: "Brandenburg",
|
||||
BE: "Berlin",
|
||||
BW: "Baden-Württemberg",
|
||||
BY: "Bayern",
|
||||
HB: "Bremen",
|
||||
HE: "Hessen",
|
||||
HH: "Hamburg",
|
||||
MV: "Mecklenburg-Vorpommern",
|
||||
NI: "Niedersachsen",
|
||||
NW: "Nordrhein-Westfalen",
|
||||
RP: "Rheinland-Pfalz",
|
||||
SH: "Schleswig-Holstein",
|
||||
SL: "Saarland",
|
||||
SN: "Sachsen",
|
||||
ST: "Sachsen-Anhalt",
|
||||
TH: "Thüringen",
|
||||
};
|
||||
|
||||
/**
|
||||
* PLZ prefix → state mapping (first 2 digits, ~85% accuracy).
|
||||
* Source: DE postal code allocation by Deutsche Post.
|
||||
*/
|
||||
const PLZ_PREFIX_MAP: Record<number, string> = {
|
||||
1: "BE", // 1xxxx – mostly Berlin
|
||||
2: "BE", // 02xxx – Cottbus area, approximation
|
||||
3: "BB", // 03xxx
|
||||
4: "MV", // 04xxx – close enough
|
||||
5: "SN", // 04-05xxx Sachsen
|
||||
6: "ST", // 06xxx Sachsen-Anhalt
|
||||
7: "TH", // 07xxx Thüringen
|
||||
8: "SN", // 08xxx Sachsen (Chemnitz region)
|
||||
9: "BY", // 09xxx Bayern (Ingolstadt south)
|
||||
10: "BE",
|
||||
11: "BE",
|
||||
12: "BE",
|
||||
13: "BE",
|
||||
14: "BB",
|
||||
15: "BB",
|
||||
16: "BB",
|
||||
17: "MV",
|
||||
18: "MV",
|
||||
19: "MV",
|
||||
20: "HH",
|
||||
21: "HH",
|
||||
22: "HH",
|
||||
23: "SH",
|
||||
24: "SH",
|
||||
25: "SH",
|
||||
26: "NI",
|
||||
27: "NI",
|
||||
28: "HB",
|
||||
29: "NI",
|
||||
30: "NI",
|
||||
31: "NI",
|
||||
32: "NW",
|
||||
33: "NW",
|
||||
34: "HE",
|
||||
35: "HE",
|
||||
36: "HE",
|
||||
37: "NI",
|
||||
38: "NI",
|
||||
39: "ST",
|
||||
40: "NW",
|
||||
41: "NW",
|
||||
42: "NW",
|
||||
44: "NW",
|
||||
45: "NW",
|
||||
46: "NW",
|
||||
47: "NW",
|
||||
48: "NW",
|
||||
49: "NI",
|
||||
50: "NW",
|
||||
51: "NW",
|
||||
52: "NW",
|
||||
53: "NW",
|
||||
54: "RP",
|
||||
55: "RP",
|
||||
56: "RP",
|
||||
57: "NW",
|
||||
58: "NW",
|
||||
59: "NW",
|
||||
60: "HE",
|
||||
61: "HE",
|
||||
63: "HE",
|
||||
64: "HE",
|
||||
65: "HE",
|
||||
66: "SL",
|
||||
67: "RP",
|
||||
68: "BW",
|
||||
69: "BW",
|
||||
70: "BW",
|
||||
71: "BW",
|
||||
72: "BW",
|
||||
73: "BW",
|
||||
74: "BW",
|
||||
75: "BW",
|
||||
76: "BW",
|
||||
77: "BW",
|
||||
78: "BW",
|
||||
79: "BW",
|
||||
80: "BY",
|
||||
81: "BY",
|
||||
82: "BY",
|
||||
83: "BY",
|
||||
84: "BY",
|
||||
85: "BY",
|
||||
86: "BY",
|
||||
87: "BY",
|
||||
88: "BW",
|
||||
89: "BY",
|
||||
90: "BY",
|
||||
91: "BY",
|
||||
92: "BY",
|
||||
93: "BY",
|
||||
94: "BY",
|
||||
95: "BY",
|
||||
96: "BY",
|
||||
97: "BY",
|
||||
98: "TH",
|
||||
99: "TH",
|
||||
};
|
||||
|
||||
/**
|
||||
* Infer federal state abbreviation from a German postal code (5 digits).
|
||||
* Returns undefined if the format is invalid or no match found.
|
||||
*/
|
||||
export function inferStateFromPostalCode(plz: string): string | undefined {
|
||||
const code = plz.trim().replace(/\s/g, "");
|
||||
if (!/^\d{5}$/.test(code)) return undefined;
|
||||
|
||||
// Try 2-digit prefix first, then 1-digit as fallback
|
||||
const prefix2 = parseInt(code.slice(0, 2), 10);
|
||||
if (PLZ_PREFIX_MAP[prefix2] !== undefined) return PLZ_PREFIX_MAP[prefix2];
|
||||
|
||||
const prefix1 = parseInt(code.slice(0, 1), 10);
|
||||
return PLZ_PREFIX_MAP[prefix1];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export * from "./germanStates.js";
|
||||
export * from "./publicHolidays.js";
|
||||
export * from "./columns.js";
|
||||
export * from "./dispo-import.js";
|
||||
|
||||
export const BUDGET_WARNING_THRESHOLDS = {
|
||||
INFO: 70,
|
||||
WARNING: 85,
|
||||
CRITICAL: 95,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_WORKING_HOURS_PER_DAY = 8;
|
||||
|
||||
export const DEFAULT_AVAILABILITY = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
} as const;
|
||||
|
||||
export const VALUE_SCORE_WEIGHTS = {
|
||||
SKILL_DEPTH: 0.30,
|
||||
SKILL_BREADTH: 0.15,
|
||||
COST_EFFICIENCY: 0.25,
|
||||
CHARGEABILITY: 0.15,
|
||||
EXPERIENCE: 0.15,
|
||||
} as const;
|
||||
|
||||
export const SCORE_WEIGHTS = {
|
||||
SKILL: 0.4,
|
||||
AVAILABILITY: 0.3,
|
||||
COST: 0.2,
|
||||
UTILIZATION: 0.1,
|
||||
} as const;
|
||||
|
||||
export const PAGINATION_DEFAULTS = {
|
||||
PAGE: 1,
|
||||
LIMIT: 50,
|
||||
MAX_LIMIT: 500,
|
||||
} as const;
|
||||
|
||||
export const SSE_EVENT_TYPES = {
|
||||
ALLOCATION_CREATED: "allocation.created",
|
||||
ALLOCATION_UPDATED: "allocation.updated",
|
||||
ALLOCATION_DELETED: "allocation.deleted",
|
||||
PROJECT_SHIFTED: "project.shifted",
|
||||
BUDGET_WARNING: "budget.warning",
|
||||
VACATION_CREATED: "vacation.created",
|
||||
VACATION_UPDATED: "vacation.updated",
|
||||
VACATION_DELETED: "vacation.deleted",
|
||||
ROLE_CREATED: "role.created",
|
||||
ROLE_UPDATED: "role.updated",
|
||||
ROLE_DELETED: "role.deleted",
|
||||
NOTIFICATION_CREATED: "notification:created",
|
||||
PING: "ping",
|
||||
} as const;
|
||||
|
||||
export type SseEventType = (typeof SSE_EVENT_TYPES)[keyof typeof SSE_EVENT_TYPES];
|
||||
|
||||
export const SSE_NOTIFICATION_CREATED = SSE_EVENT_TYPES.NOTIFICATION_CREATED;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* German public holiday calculator.
|
||||
* Supports federal holidays + Bavaria (BY) specific holidays.
|
||||
*
|
||||
* Easter-based dates use the Gauss/Meeus algorithm.
|
||||
*/
|
||||
|
||||
export interface PublicHoliday {
|
||||
date: string; // ISO "YYYY-MM-DD"
|
||||
name: string;
|
||||
federal: boolean; // true = all states; false = state-specific
|
||||
states?: string[]; // which state abbreviations observe this holiday
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute Easter Sunday date for a given year (Gregorian calendar).
|
||||
* Uses the Anonymous Gregorian algorithm.
|
||||
*/
|
||||
function computeEaster(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31); // 1-based
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function fmt(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fixed(year: number, month: number, day: number): string {
|
||||
return fmt(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all public holidays for a given year and optional state.
|
||||
* When state is omitted, returns federal holidays only.
|
||||
* When state is provided (e.g. "BY"), returns federal + state-specific holidays.
|
||||
*/
|
||||
export function getPublicHolidays(year: number, state?: string): PublicHoliday[] {
|
||||
const easter = computeEaster(year);
|
||||
|
||||
const holidays: PublicHoliday[] = [
|
||||
// Federal holidays (all states)
|
||||
{ date: fixed(year, 1, 1), name: "Neujahr", federal: true },
|
||||
{ date: fixed(year, 5, 1), name: "Tag der Arbeit", federal: true },
|
||||
{ date: fixed(year, 10, 3), name: "Tag der Deutschen Einheit", federal: true },
|
||||
{ date: fixed(year, 12, 25), name: "1. Weihnachtstag", federal: true },
|
||||
{ date: fixed(year, 12, 26), name: "2. Weihnachtstag", federal: true },
|
||||
// Easter-based federal holidays
|
||||
{ date: fmt(addDays(easter, -2)), name: "Karfreitag", federal: true },
|
||||
{ date: fmt(easter), name: "Ostersonntag", federal: true },
|
||||
{ date: fmt(addDays(easter, 1)), name: "Ostermontag", federal: true },
|
||||
{ date: fmt(addDays(easter, 39)), name: "Christi Himmelfahrt", federal: true },
|
||||
{ date: fmt(addDays(easter, 49)), name: "Pfingstsonntag", federal: true },
|
||||
{ date: fmt(addDays(easter, 50)), name: "Pfingstmontag", federal: true },
|
||||
|
||||
// Bavaria-specific (BY)
|
||||
{
|
||||
date: fixed(year, 1, 6),
|
||||
name: "Heilige Drei Könige",
|
||||
federal: false,
|
||||
states: ["BY", "BW", "ST"],
|
||||
},
|
||||
{
|
||||
date: fmt(addDays(easter, 60)),
|
||||
name: "Fronleichnam",
|
||||
federal: false,
|
||||
states: ["BY", "BW", "HE", "NW", "RP", "SL"],
|
||||
},
|
||||
{
|
||||
date: fixed(year, 8, 15),
|
||||
name: "Mariä Himmelfahrt",
|
||||
federal: false,
|
||||
states: ["BY", "SL"],
|
||||
},
|
||||
{
|
||||
date: fixed(year, 11, 1),
|
||||
name: "Allerheiligen",
|
||||
federal: false,
|
||||
states: ["BY", "BW", "NW", "RP", "SL"],
|
||||
},
|
||||
|
||||
// Other state-specific (not BY but included for completeness)
|
||||
{
|
||||
date: fixed(year, 10, 31),
|
||||
name: "Reformationstag",
|
||||
federal: false,
|
||||
states: ["BB", "HB", "HH", "MV", "NI", "SH", "SN", "ST", "TH"],
|
||||
},
|
||||
{
|
||||
date: fixed(year, 11, 18),
|
||||
name: "Buß- und Bettag",
|
||||
federal: false,
|
||||
states: ["SN"],
|
||||
},
|
||||
];
|
||||
|
||||
if (!state) {
|
||||
return holidays.filter((h) => h.federal);
|
||||
}
|
||||
|
||||
return holidays.filter((h) => h.federal || h.states?.includes(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given date (ISO string or Date) is a public holiday.
|
||||
*/
|
||||
export function isPublicHoliday(date: Date | string, state?: string): boolean {
|
||||
const d = typeof date === "string" ? date : date.toISOString().slice(0, 10);
|
||||
const year = parseInt(d.slice(0, 4), 10);
|
||||
return getPublicHolidays(year, state).some((h) => h.date === d);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types/index.js";
|
||||
export * from "./schemas/index.js";
|
||||
export * from "./constants/index.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from "zod";
|
||||
import { AllocationStatus } from "../types/enums.js";
|
||||
|
||||
export const CreateAllocationBaseSchema = z.object({
|
||||
resourceId: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0).max(24),
|
||||
percentage: z.number().min(0).max(100),
|
||||
role: z.string().max(200).optional(),
|
||||
roleId: z.string().optional(),
|
||||
headcount: z.number().int().min(1).default(1),
|
||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const CreateDemandRequirementBaseSchema = z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0).max(24),
|
||||
percentage: z.number().min(0).max(100),
|
||||
role: z.string().max(200).optional(),
|
||||
roleId: z.string().optional(),
|
||||
headcount: z.number().int().min(1).default(1),
|
||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const CreateAssignmentBaseSchema = z.object({
|
||||
demandRequirementId: z.string().optional(),
|
||||
resourceId: z.string(),
|
||||
projectId: z.string(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
hoursPerDay: z.number().min(0).max(24),
|
||||
percentage: z.number().min(0).max(100),
|
||||
role: z.string().max(200).optional(),
|
||||
roleId: z.string().optional(),
|
||||
dailyCostCents: z.number().int().min(0).optional(),
|
||||
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const CreateAllocationSchema = CreateAllocationBaseSchema.refine(
|
||||
(data) => data.endDate >= data.startDate,
|
||||
{ message: "End date must be after start date", path: ["endDate"] },
|
||||
);
|
||||
|
||||
export const UpdateAllocationSchema = CreateAllocationBaseSchema.partial();
|
||||
|
||||
export const CreateDemandRequirementSchema = CreateDemandRequirementBaseSchema.refine(
|
||||
(data) => data.endDate >= data.startDate,
|
||||
{ message: "End date must be after start date", path: ["endDate"] },
|
||||
);
|
||||
|
||||
export const UpdateDemandRequirementSchema = CreateDemandRequirementBaseSchema.partial();
|
||||
|
||||
export const CreateAssignmentSchema = CreateAssignmentBaseSchema.refine(
|
||||
(data) => data.endDate >= data.startDate,
|
||||
{ message: "End date must be after start date", path: ["endDate"] },
|
||||
);
|
||||
|
||||
export const UpdateAssignmentSchema = CreateAssignmentBaseSchema.partial();
|
||||
|
||||
export const FillDemandRequirementSchema = z.object({
|
||||
demandRequirementId: z.string(),
|
||||
resourceId: z.string(),
|
||||
hoursPerDay: z.number().min(0.5).max(24).optional(),
|
||||
status: z.nativeEnum(AllocationStatus).optional(),
|
||||
});
|
||||
|
||||
export const FillOpenDemandByAllocationSchema = z.object({
|
||||
allocationId: z.string(),
|
||||
resourceId: z.string(),
|
||||
hoursPerDay: z.number().min(0.5).max(24).optional(),
|
||||
status: z.nativeEnum(AllocationStatus).optional(),
|
||||
});
|
||||
|
||||
export const ShiftProjectSchema = z
|
||||
.object({
|
||||
projectId: z.string(),
|
||||
newStartDate: z.coerce.date(),
|
||||
newEndDate: z.coerce.date(),
|
||||
})
|
||||
.refine((data) => data.newEndDate >= data.newStartDate, {
|
||||
message: "New end date must be after new start date",
|
||||
path: ["newEndDate"],
|
||||
});
|
||||
|
||||
export const UpdateAllocationHoursSchema = z.object({
|
||||
allocationId: z.string(),
|
||||
hoursPerDay: z.number().min(0.5).max(24).optional(),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
includeSaturday: z.boolean().optional(),
|
||||
role: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export type CreateAllocationInput = z.infer<typeof CreateAllocationSchema>;
|
||||
export type UpdateAllocationInput = z.infer<typeof UpdateAllocationSchema>;
|
||||
export type CreateDemandRequirementInput = z.infer<typeof CreateDemandRequirementSchema>;
|
||||
export type UpdateDemandRequirementInput = z.infer<typeof UpdateDemandRequirementSchema>;
|
||||
export type CreateAssignmentInput = z.infer<typeof CreateAssignmentSchema>;
|
||||
export type UpdateAssignmentInput = z.infer<typeof UpdateAssignmentSchema>;
|
||||
export type FillDemandRequirementInput = z.infer<typeof FillDemandRequirementSchema>;
|
||||
export type FillOpenDemandByAllocationInput = z.infer<typeof FillOpenDemandByAllocationSchema>;
|
||||
export type ShiftProjectInput = z.infer<typeof ShiftProjectSchema>;
|
||||
export type UpdateAllocationHoursInput = z.infer<typeof UpdateAllocationHoursSchema>;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from "zod";
|
||||
import { BlueprintTarget, FieldType } from "../types/enums.js";
|
||||
|
||||
export const FieldOptionSchema = z.object({
|
||||
value: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FieldValidationSchema = z.object({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
minLength: z.number().int().optional(),
|
||||
maxLength: z.number().int().optional(),
|
||||
pattern: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export const BlueprintFieldDefinitionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).max(200),
|
||||
key: z.string().min(1).max(100).regex(/^[a-z_][a-z0-9_]*$/, "Must be snake_case"),
|
||||
type: z.nativeEnum(FieldType),
|
||||
required: z.boolean().default(false),
|
||||
description: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.unknown().optional(),
|
||||
options: z.array(FieldOptionSchema).optional(),
|
||||
validation: FieldValidationSchema.optional(),
|
||||
order: z.number().int().min(0),
|
||||
group: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateBlueprintSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
target: z.nativeEnum(BlueprintTarget),
|
||||
description: z.string().optional(),
|
||||
fieldDefs: z.array(BlueprintFieldDefinitionSchema).default([]),
|
||||
defaults: z.record(z.string(), z.unknown()).default({}),
|
||||
validationRules: z.array(z.object({
|
||||
field: z.string(),
|
||||
rule: z.enum(["required_if", "unique", "min", "max"]),
|
||||
params: z.unknown().optional(),
|
||||
message: z.string().optional(),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
export const UpdateBlueprintSchema = CreateBlueprintSchema.partial();
|
||||
|
||||
export type CreateBlueprintInput = z.infer<typeof CreateBlueprintSchema>;
|
||||
export type UpdateBlueprintInput = z.infer<typeof UpdateBlueprintSchema>;
|
||||
|
||||
/** Generate a Zod schema from blueprint field definitions at runtime */
|
||||
export function generateDynamicZodSchema(
|
||||
fieldDefs: z.infer<typeof BlueprintFieldDefinitionSchema>[],
|
||||
): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
for (const field of fieldDefs) {
|
||||
let fieldSchema: z.ZodTypeAny;
|
||||
|
||||
switch (field.type) {
|
||||
case FieldType.TEXT:
|
||||
case FieldType.TEXTAREA:
|
||||
case FieldType.URL:
|
||||
case FieldType.EMAIL:
|
||||
fieldSchema = z.string();
|
||||
if (field.validation?.minLength !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).min(field.validation.minLength);
|
||||
}
|
||||
if (field.validation?.maxLength !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).max(field.validation.maxLength);
|
||||
}
|
||||
if (field.type === FieldType.EMAIL) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).email();
|
||||
}
|
||||
if (field.type === FieldType.URL) {
|
||||
fieldSchema = (fieldSchema as z.ZodString).url();
|
||||
}
|
||||
break;
|
||||
case FieldType.NUMBER:
|
||||
fieldSchema = z.number();
|
||||
if (field.validation?.min !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodNumber).min(field.validation.min);
|
||||
}
|
||||
if (field.validation?.max !== undefined) {
|
||||
fieldSchema = (fieldSchema as z.ZodNumber).max(field.validation.max);
|
||||
}
|
||||
break;
|
||||
case FieldType.BOOLEAN:
|
||||
fieldSchema = z.boolean();
|
||||
break;
|
||||
case FieldType.DATE:
|
||||
fieldSchema = z.coerce.date();
|
||||
break;
|
||||
case FieldType.SELECT:
|
||||
if (field.options && field.options.length > 0) {
|
||||
const values = field.options.map((o) => o.value) as [string, ...string[]];
|
||||
fieldSchema = z.enum(values);
|
||||
} else {
|
||||
fieldSchema = z.string();
|
||||
}
|
||||
break;
|
||||
case FieldType.MULTI_SELECT:
|
||||
if (field.options && field.options.length > 0) {
|
||||
const values = field.options.map((o) => o.value) as [string, ...string[]];
|
||||
fieldSchema = z.array(z.enum(values));
|
||||
} else {
|
||||
fieldSchema = z.array(z.string());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
fieldSchema = z.unknown();
|
||||
}
|
||||
|
||||
if (!field.required) {
|
||||
fieldSchema = fieldSchema.optional();
|
||||
}
|
||||
|
||||
shape[field.key] = fieldSchema;
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CreateClientSchema = z.object({
|
||||
name: z.string().min(1).max(300),
|
||||
code: z.string().max(50).optional(),
|
||||
parentId: z.string().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export const UpdateClientSchema = z.object({
|
||||
name: z.string().min(1).max(300).optional(),
|
||||
code: z.string().max(50).nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateClientInput = z.infer<typeof CreateClientSchema>;
|
||||
export type UpdateClientInput = z.infer<typeof UpdateClientSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const SpainScheduleRuleSchema = z.object({
|
||||
type: z.literal("spain"),
|
||||
fridayHours: z.number().positive(),
|
||||
summerPeriod: z.object({
|
||||
from: z.string().regex(/^\d{2}-\d{2}$/),
|
||||
to: z.string().regex(/^\d{2}-\d{2}$/),
|
||||
}),
|
||||
summerHours: z.number().positive(),
|
||||
regularHours: z.number().positive(),
|
||||
});
|
||||
|
||||
export const CreateCountrySchema = z.object({
|
||||
code: z.string().min(2).max(3).toUpperCase(),
|
||||
name: z.string().min(1).max(100),
|
||||
dailyWorkingHours: z.number().positive().max(24).default(8),
|
||||
scheduleRules: SpainScheduleRuleSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export const UpdateCountrySchema = CreateCountrySchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CreateMetroCitySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
countryId: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateMetroCitySchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export type CreateCountryInput = z.infer<typeof CreateCountrySchema>;
|
||||
export type UpdateCountryInput = z.infer<typeof UpdateCountrySchema>;
|
||||
export type CreateMetroCityInput = z.infer<typeof CreateMetroCitySchema>;
|
||||
export type UpdateMetroCityInput = z.infer<typeof UpdateMetroCitySchema>;
|
||||
@@ -0,0 +1,228 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DASHBOARD_GRID_COLUMNS,
|
||||
DASHBOARD_LAYOUT_VERSION,
|
||||
DASHBOARD_WIDGET_CATALOG,
|
||||
DASHBOARD_WIDGET_TYPES,
|
||||
type DashboardLayoutConfig,
|
||||
type DashboardWidgetCatalogEntry,
|
||||
type DashboardWidgetConfigMap,
|
||||
type DashboardWidgetInstance,
|
||||
type DashboardWidgetType,
|
||||
} from "../types/dashboard.js";
|
||||
import { ProjectStatus } from "../types/enums.js";
|
||||
|
||||
const DASHBOARD_WIDGET_BY_TYPE = Object.fromEntries(
|
||||
DASHBOARD_WIDGET_CATALOG.map((widget) => [widget.type, widget]),
|
||||
) as Record<DashboardWidgetType, DashboardWidgetCatalogEntry>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function toInt(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : undefined;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
const dashboardWidgetTypeSchema = z.enum(DASHBOARD_WIDGET_TYPES);
|
||||
|
||||
const resourceTableWidgetConfigSchema = z.object({
|
||||
chapter: z.preprocess(toNonEmptyString, z.string().optional()),
|
||||
});
|
||||
|
||||
const projectTableWidgetConfigSchema = z.object({
|
||||
search: z.preprocess(toNonEmptyString, z.string().optional()),
|
||||
status: z.nativeEnum(ProjectStatus).optional(),
|
||||
});
|
||||
|
||||
const peakTimesWidgetConfigSchema = z.object({
|
||||
granularity: z.enum(["week", "month"]).optional(),
|
||||
groupBy: z.enum(["project", "chapter", "resource"]).optional(),
|
||||
});
|
||||
|
||||
const demandWidgetConfigSchema = z.object({
|
||||
groupBy: z.enum(["project", "person", "chapter"]).optional(),
|
||||
});
|
||||
|
||||
const topValueWidgetConfigSchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
const chargeabilityWidgetConfigSchema = z.object({
|
||||
topN: z.number().int().min(1).max(100).optional(),
|
||||
watchlistThreshold: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
export const dashboardWidgetConfigSchemas = {
|
||||
"stat-cards": z.object({}),
|
||||
"resource-table": resourceTableWidgetConfigSchema,
|
||||
"project-table": projectTableWidgetConfigSchema,
|
||||
"peak-times-chart": peakTimesWidgetConfigSchema,
|
||||
"demand-view": demandWidgetConfigSchema,
|
||||
"top-value-resources": topValueWidgetConfigSchema,
|
||||
"chargeability-overview": chargeabilityWidgetConfigSchema,
|
||||
} as const;
|
||||
|
||||
type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas;
|
||||
|
||||
export function getDashboardWidgetCatalogEntry(type: DashboardWidgetType): DashboardWidgetCatalogEntry {
|
||||
return DASHBOARD_WIDGET_BY_TYPE[type];
|
||||
}
|
||||
|
||||
export function getNextDashboardWidgetY(widgets: DashboardWidgetInstance[]): number {
|
||||
return widgets.reduce((max, widget) => Math.max(max, widget.y + widget.h), 0);
|
||||
}
|
||||
|
||||
export function normalizeDashboardWidgetConfig<T extends DashboardWidgetType>(
|
||||
type: T,
|
||||
config: unknown,
|
||||
): DashboardWidgetConfigMap[T] {
|
||||
const schema = dashboardWidgetConfigSchemas[type];
|
||||
const parsed = schema.safeParse(isRecord(config) ? config : {});
|
||||
return {
|
||||
...DASHBOARD_WIDGET_BY_TYPE[type].defaultConfig,
|
||||
...(parsed.success ? parsed.data : {}),
|
||||
} as DashboardWidgetConfigMap[T];
|
||||
}
|
||||
|
||||
export function createDashboardWidget<T extends DashboardWidgetType>(
|
||||
type: T,
|
||||
options: {
|
||||
id: string;
|
||||
title?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
config?: unknown;
|
||||
},
|
||||
): DashboardWidgetInstance<T> {
|
||||
const widgetDef = DASHBOARD_WIDGET_BY_TYPE[type];
|
||||
const minW = Math.max(1, toInt(options.minW) ?? widgetDef.minSize.w);
|
||||
const minH = Math.max(1, toInt(options.minH) ?? widgetDef.minSize.h);
|
||||
const w = clamp(Math.max(minW, toInt(options.w) ?? widgetDef.defaultSize.w), minW, DASHBOARD_GRID_COLUMNS);
|
||||
const h = Math.max(minH, toInt(options.h) ?? widgetDef.defaultSize.h);
|
||||
const title = toNonEmptyString(options.title);
|
||||
|
||||
return {
|
||||
id: options.id,
|
||||
type,
|
||||
x: clamp(Math.max(0, toInt(options.x) ?? 0), 0, Math.max(0, DASHBOARD_GRID_COLUMNS - w)),
|
||||
y: Math.max(0, toInt(options.y) ?? 0),
|
||||
w,
|
||||
h,
|
||||
minW,
|
||||
minH,
|
||||
config: normalizeDashboardWidgetConfig(type, options.config),
|
||||
...(title ? { title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultDashboardLayout(): DashboardLayoutConfig {
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
gridCols: DASHBOARD_GRID_COLUMNS,
|
||||
widgets: [
|
||||
createDashboardWidget("stat-cards", {
|
||||
id: "default-stat-cards",
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDashboardLayout(input: unknown): DashboardLayoutConfig {
|
||||
if (!isRecord(input)) {
|
||||
return createDefaultDashboardLayout();
|
||||
}
|
||||
|
||||
const gridCols = clamp(Math.max(1, toInt(input.gridCols) ?? DASHBOARD_GRID_COLUMNS), 1, 24);
|
||||
const rawWidgets = Array.isArray(input.widgets) ? input.widgets : [];
|
||||
const widgets: DashboardWidgetInstance[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let nextY = 0;
|
||||
|
||||
rawWidgets.forEach((rawWidget, index) => {
|
||||
if (!isRecord(rawWidget)) return;
|
||||
|
||||
const typeResult = dashboardWidgetTypeSchema.safeParse(rawWidget.type);
|
||||
if (!typeResult.success) return;
|
||||
|
||||
const type = typeResult.data;
|
||||
const baseId = toNonEmptyString(rawWidget.id) ?? `${type}-${index + 1}`;
|
||||
let id = baseId;
|
||||
let suffix = 1;
|
||||
while (seenIds.has(id)) {
|
||||
suffix += 1;
|
||||
id = `${baseId}-${suffix}`;
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
const widgetOptions: Parameters<typeof createDashboardWidget<typeof type>>[1] = {
|
||||
id,
|
||||
...(typeof rawWidget.title === "string" ? { title: rawWidget.title } : {}),
|
||||
...(typeof rawWidget.x === "number" ? { x: rawWidget.x } : {}),
|
||||
...(typeof rawWidget.y === "number" ? { y: rawWidget.y } : {}),
|
||||
...(typeof rawWidget.w === "number" ? { w: rawWidget.w } : {}),
|
||||
...(typeof rawWidget.h === "number" ? { h: rawWidget.h } : {}),
|
||||
...(typeof rawWidget.minW === "number" ? { minW: rawWidget.minW } : {}),
|
||||
...(typeof rawWidget.minH === "number" ? { minH: rawWidget.minH } : {}),
|
||||
...(rawWidget.config !== undefined ? { config: rawWidget.config } : {}),
|
||||
};
|
||||
|
||||
const widget = createDashboardWidget(type, widgetOptions);
|
||||
|
||||
const placedWidget = {
|
||||
...widget,
|
||||
x: clamp(widget.x, 0, Math.max(0, gridCols - widget.w)),
|
||||
y: toInt(rawWidget.y) !== undefined && (toInt(rawWidget.y) ?? 0) >= 0 ? widget.y : nextY,
|
||||
w: clamp(widget.w, widget.minW, gridCols),
|
||||
};
|
||||
|
||||
widgets.push(placedWidget);
|
||||
nextY = Math.max(nextY, placedWidget.y + placedWidget.h);
|
||||
});
|
||||
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
gridCols,
|
||||
widgets,
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardWidgetInstanceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: dashboardWidgetTypeSchema,
|
||||
title: z.string().min(1).optional(),
|
||||
x: z.number().int().min(0),
|
||||
y: z.number().int().min(0),
|
||||
w: z.number().int().min(1),
|
||||
h: z.number().int().min(1),
|
||||
minW: z.number().int().min(1),
|
||||
minH: z.number().int().min(1),
|
||||
config: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export const dashboardLayoutSchema = z
|
||||
.unknown()
|
||||
.transform((value) => normalizeDashboardLayout(value))
|
||||
.pipe(
|
||||
z.object({
|
||||
version: z.literal(DASHBOARD_LAYOUT_VERSION),
|
||||
widgets: z.array(dashboardWidgetInstanceSchema),
|
||||
gridCols: z.number().int().min(1).max(24),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,169 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AllocationType,
|
||||
DispoImportSourceKind,
|
||||
DispoStagedRecordType,
|
||||
ImportBatchStatus,
|
||||
OrderType,
|
||||
ResourceType,
|
||||
StagedRecordStatus,
|
||||
VacationType,
|
||||
} from "../types/enums.js";
|
||||
|
||||
const JsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
const StagedTraceSchema = z.object({
|
||||
importBatchId: z.string(),
|
||||
status: z.nativeEnum(StagedRecordStatus),
|
||||
sourceKind: z.nativeEnum(DispoImportSourceKind),
|
||||
sourceWorkbook: z.string().min(1),
|
||||
sourceSheet: z.string().min(1),
|
||||
sourceRow: z.number().int().nonnegative(),
|
||||
sourceColumn: z.string().min(1).optional().nullable(),
|
||||
warnings: z.array(z.string()).default([]),
|
||||
errorMessage: z.string().optional().nullable(),
|
||||
rawPayload: z.unknown(),
|
||||
normalizedData: JsonRecordSchema.default({}),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const ImportBatchSchema = z.object({
|
||||
id: z.string(),
|
||||
sourceSystem: z.string().min(1),
|
||||
status: z.nativeEnum(ImportBatchStatus),
|
||||
referenceSourceFile: z.string().optional().nullable(),
|
||||
planningSourceFile: z.string().optional().nullable(),
|
||||
chargeabilitySourceFile: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
summary: JsonRecordSchema.default({}),
|
||||
startedAt: z.coerce.date().optional().nullable(),
|
||||
stagedAt: z.coerce.date().optional().nullable(),
|
||||
approvedAt: z.coerce.date().optional().nullable(),
|
||||
committedAt: z.coerce.date().optional().nullable(),
|
||||
failedAt: z.coerce.date().optional().nullable(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const StagedResourceSchema = StagedTraceSchema.extend({
|
||||
id: z.string(),
|
||||
canonicalExternalId: z.string().min(1),
|
||||
enterpriseId: z.string().optional().nullable(),
|
||||
eid: z.string().optional().nullable(),
|
||||
displayName: z.string().optional().nullable(),
|
||||
email: z.string().email().optional().nullable(),
|
||||
chapter: z.string().optional().nullable(),
|
||||
chapterCode: z.string().optional().nullable(),
|
||||
managementLevelGroupName: z.string().optional().nullable(),
|
||||
managementLevelName: z.string().optional().nullable(),
|
||||
countryCode: z.string().optional().nullable(),
|
||||
metroCityName: z.string().optional().nullable(),
|
||||
clientUnitName: z.string().optional().nullable(),
|
||||
resourceType: z.nativeEnum(ResourceType).optional().nullable(),
|
||||
chargeabilityTarget: z.number().min(0).max(100).optional().nullable(),
|
||||
fte: z.number().min(0).max(1).optional().nullable(),
|
||||
lcrCents: z.number().int().min(0).optional().nullable(),
|
||||
ucrCents: z.number().int().min(0).optional().nullable(),
|
||||
availability: JsonRecordSchema.optional().nullable(),
|
||||
roleTokens: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const StagedClientSchema = StagedTraceSchema.extend({
|
||||
id: z.string(),
|
||||
clientCode: z.string().optional().nullable(),
|
||||
parentClientCode: z.string().optional().nullable(),
|
||||
name: z.string().min(1),
|
||||
sortOrder: z.number().int().optional().nullable(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const StagedProjectSchema = StagedTraceSchema.extend({
|
||||
id: z.string(),
|
||||
projectKey: z.string().min(1),
|
||||
shortCode: z.string().optional().nullable(),
|
||||
name: z.string().optional().nullable(),
|
||||
clientCode: z.string().optional().nullable(),
|
||||
utilizationCategoryCode: z.string().optional().nullable(),
|
||||
orderType: z.nativeEnum(OrderType).optional().nullable(),
|
||||
allocationType: z.nativeEnum(AllocationType).optional().nullable(),
|
||||
winProbability: z.number().int().min(0).max(100).optional().nullable(),
|
||||
isInternal: z.boolean().default(false),
|
||||
isTbd: z.boolean().default(false),
|
||||
startDate: z.coerce.date().optional().nullable(),
|
||||
endDate: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
export const StagedAssignmentSchema = StagedTraceSchema.extend({
|
||||
id: z.string(),
|
||||
resourceExternalId: z.string().min(1),
|
||||
projectKey: z.string().optional().nullable(),
|
||||
assignmentDate: z.coerce.date().optional().nullable(),
|
||||
startDate: z.coerce.date().optional().nullable(),
|
||||
endDate: z.coerce.date().optional().nullable(),
|
||||
hoursPerDay: z.number().min(0).max(24).optional().nullable(),
|
||||
percentage: z.number().min(0).max(100).optional().nullable(),
|
||||
slotFraction: z.number().min(0).max(1).optional().nullable(),
|
||||
roleToken: z.string().optional().nullable(),
|
||||
roleName: z.string().optional().nullable(),
|
||||
chapterToken: z.string().optional().nullable(),
|
||||
utilizationCategoryCode: z.string().optional().nullable(),
|
||||
winProbability: z.number().int().min(0).max(100).optional().nullable(),
|
||||
isInternal: z.boolean().default(false),
|
||||
isUnassigned: z.boolean().default(false),
|
||||
isTbd: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const StagedVacationSchema = StagedTraceSchema.extend({
|
||||
id: z.string(),
|
||||
resourceExternalId: z.string().min(1),
|
||||
vacationType: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().optional().nullable(),
|
||||
holidayName: z.string().optional().nullable(),
|
||||
isHalfDay: z.boolean().default(false),
|
||||
halfDayPart: z.string().optional().nullable(),
|
||||
isPublicHoliday: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const StagedAvailabilityRuleSchema = StagedTraceSchema.extend({
|
||||
id: z.string(),
|
||||
resourceExternalId: z.string().min(1),
|
||||
ruleType: z.string().min(1),
|
||||
weekday: z.number().int().min(0).max(6).optional().nullable(),
|
||||
effectiveStartDate: z.coerce.date().optional().nullable(),
|
||||
effectiveEndDate: z.coerce.date().optional().nullable(),
|
||||
availableHours: z.number().min(0).max(24).optional().nullable(),
|
||||
percentage: z.number().min(0).max(100).optional().nullable(),
|
||||
isResolved: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const StagedUnresolvedRecordSchema = z.object({
|
||||
id: z.string(),
|
||||
importBatchId: z.string(),
|
||||
status: z.nativeEnum(StagedRecordStatus),
|
||||
sourceKind: z.nativeEnum(DispoImportSourceKind),
|
||||
sourceWorkbook: z.string().min(1),
|
||||
sourceSheet: z.string().min(1),
|
||||
sourceRow: z.number().int().nonnegative(),
|
||||
sourceColumn: z.string().min(1).optional().nullable(),
|
||||
recordType: z.nativeEnum(DispoStagedRecordType),
|
||||
resourceExternalId: z.string().optional().nullable(),
|
||||
projectKey: z.string().optional().nullable(),
|
||||
message: z.string().min(1),
|
||||
resolutionHint: z.string().optional().nullable(),
|
||||
warnings: z.array(z.string()).default([]),
|
||||
rawPayload: z.unknown(),
|
||||
normalizedData: JsonRecordSchema.default({}),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type ImportBatchInput = z.infer<typeof ImportBatchSchema>;
|
||||
export type StagedResourceInput = z.infer<typeof StagedResourceSchema>;
|
||||
export type StagedClientInput = z.infer<typeof StagedClientSchema>;
|
||||
export type StagedProjectInput = z.infer<typeof StagedProjectSchema>;
|
||||
export type StagedAssignmentInput = z.infer<typeof StagedAssignmentSchema>;
|
||||
export type StagedVacationInput = z.infer<typeof StagedVacationSchema>;
|
||||
export type StagedAvailabilityRuleInput = z.infer<typeof StagedAvailabilityRuleSchema>;
|
||||
export type StagedUnresolvedRecordInput = z.infer<typeof StagedUnresolvedRecordSchema>;
|
||||
@@ -0,0 +1,356 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "../types/enums.js";
|
||||
|
||||
const jsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
const numericRecordSchema = z.record(z.string(), z.number());
|
||||
const demandLineRateModeSchema = z.enum(["resource", "manual"]);
|
||||
|
||||
export const EstimateDemandLineCalculationMetadataSchema = z.object({
|
||||
costRateMode: demandLineRateModeSchema.default("manual"),
|
||||
billRateMode: demandLineRateModeSchema.default("manual"),
|
||||
totalMode: z.literal("computed").default("computed"),
|
||||
liveCostRateCents: z.number().int().min(0).nullable().optional(),
|
||||
liveBillRateCents: z.number().int().min(0).nullable().optional(),
|
||||
liveCurrency: z.string().length(3).nullable().optional(),
|
||||
});
|
||||
|
||||
export const EstimateDemandLineMetadataSchema = z
|
||||
.object({
|
||||
calculation: EstimateDemandLineCalculationMetadataSchema.optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const EstimateAssumptionSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
category: z.string().min(1).max(100),
|
||||
key: z.string().min(1).max(100),
|
||||
label: z.string().min(1).max(200),
|
||||
valueType: z.string().min(1).max(50).default("json"),
|
||||
value: z.unknown(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
notes: z.string().max(2_000).optional(),
|
||||
});
|
||||
|
||||
export const ScopeItemSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
sequenceNo: z.number().int().min(0),
|
||||
scopeType: z.string().min(1).max(100),
|
||||
packageCode: z.string().max(100).optional(),
|
||||
name: z.string().min(1).max(500),
|
||||
description: z.string().max(5_000).optional(),
|
||||
scene: z.string().max(200).optional(),
|
||||
page: z.string().max(100).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
assumptionCategory: z.string().max(100).optional(),
|
||||
technicalSpec: jsonRecordSchema.default({}),
|
||||
frameCount: z.number().int().min(0).optional(),
|
||||
itemCount: z.number().min(0).optional(),
|
||||
unitMode: z.string().max(100).optional(),
|
||||
internalComments: z.string().max(5_000).optional(),
|
||||
externalComments: z.string().max(5_000).optional(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
metadata: jsonRecordSchema.default({}),
|
||||
});
|
||||
|
||||
export const EstimateDemandLineSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
scopeItemId: z.string().optional(),
|
||||
roleId: z.string().optional(),
|
||||
resourceId: z.string().optional(),
|
||||
lineType: z.string().min(1).max(100).default("LABOR"),
|
||||
name: z.string().min(1).max(500),
|
||||
chapter: z.string().max(200).optional(),
|
||||
hours: z.number().min(0),
|
||||
days: z.number().min(0).optional(),
|
||||
fte: z.number().min(0).optional(),
|
||||
rateSource: z.string().max(200).optional(),
|
||||
costRateCents: z.number().int().min(0).default(0),
|
||||
billRateCents: z.number().int().min(0).default(0),
|
||||
currency: z.string().length(3).default("EUR"),
|
||||
costTotalCents: z.number().int().min(0).default(0),
|
||||
priceTotalCents: z.number().int().min(0).default(0),
|
||||
monthlySpread: numericRecordSchema.default({}),
|
||||
staffingAttributes: jsonRecordSchema.default({}),
|
||||
metadata: EstimateDemandLineMetadataSchema.default({}),
|
||||
});
|
||||
|
||||
export const ResourceCostSnapshotSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
resourceId: z.string().optional(),
|
||||
sourceEid: z.string().max(100).optional(),
|
||||
displayName: z.string().min(1).max(500),
|
||||
chapter: z.string().max(200).optional(),
|
||||
roleId: z.string().optional(),
|
||||
currency: z.string().length(3).default("EUR"),
|
||||
lcrCents: z.number().int().min(0),
|
||||
ucrCents: z.number().int().min(0),
|
||||
fte: z.number().min(0).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
country: z.string().max(200).optional(),
|
||||
level: z.string().max(100).optional(),
|
||||
workType: z.string().max(100).optional(),
|
||||
attributes: jsonRecordSchema.default({}),
|
||||
});
|
||||
|
||||
export const EstimateMetricSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
key: z.string().min(1).max(100),
|
||||
label: z.string().min(1).max(200),
|
||||
metricGroup: z.string().max(100).optional(),
|
||||
valueDecimal: z.number(),
|
||||
valueCents: z.number().int().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
metadata: jsonRecordSchema.default({}),
|
||||
});
|
||||
|
||||
export const EstimateExportSummarySchema = z.object({
|
||||
estimateId: z.string(),
|
||||
estimateName: z.string().min(1).max(500),
|
||||
versionId: z.string(),
|
||||
versionNumber: z.number().int().min(1),
|
||||
versionStatus: z.nativeEnum(EstimateVersionStatus),
|
||||
projectId: z.string().nullable().optional(),
|
||||
projectName: z.string().nullable().optional(),
|
||||
baseCurrency: z.string().length(3),
|
||||
assumptionCount: z.number().int().min(0),
|
||||
scopeItemCount: z.number().int().min(0),
|
||||
demandLineCount: z.number().int().min(0),
|
||||
resourceSnapshotCount: z.number().int().min(0),
|
||||
totalHours: z.number().min(0),
|
||||
totalCostCents: z.number().int(),
|
||||
totalPriceCents: z.number().int(),
|
||||
marginCents: z.number().int(),
|
||||
marginPercent: z.number(),
|
||||
});
|
||||
|
||||
export const EstimateExportArtifactPayloadSchema = z.object({
|
||||
schemaVersion: z.number().int().min(1).default(1),
|
||||
format: z.nativeEnum(EstimateExportFormat),
|
||||
mimeType: z.string().min(1).max(200),
|
||||
encoding: z.enum(["utf8", "base64"]),
|
||||
fileExtension: z.string().min(1).max(20),
|
||||
generatedAt: z.string().datetime(),
|
||||
byteLength: z.number().int().min(0),
|
||||
rowCount: z.number().int().min(0).nullable().optional(),
|
||||
lineCount: z.number().int().min(0).nullable().optional(),
|
||||
sheetNames: z.array(z.string().min(1).max(200)).optional(),
|
||||
previewText: z.string().nullable().optional(),
|
||||
content: z.string(),
|
||||
summary: EstimateExportSummarySchema,
|
||||
});
|
||||
|
||||
export const EstimateExportSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
format: z.nativeEnum(EstimateExportFormat),
|
||||
fileName: z.string().min(1).max(500),
|
||||
storageKey: z.string().max(500).optional(),
|
||||
payload: z.union([EstimateExportArtifactPayloadSchema, jsonRecordSchema]).optional(),
|
||||
});
|
||||
|
||||
export const EstimateVersionSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
versionNumber: z.number().int().min(1).default(1),
|
||||
label: z.string().max(200).optional(),
|
||||
status: z.nativeEnum(EstimateVersionStatus).default(
|
||||
EstimateVersionStatus.WORKING,
|
||||
),
|
||||
notes: z.string().max(5_000).optional(),
|
||||
lockedAt: z.coerce.date().optional(),
|
||||
projectSnapshot: jsonRecordSchema.default({}),
|
||||
assumptions: z.array(EstimateAssumptionSchema).default([]),
|
||||
scopeItems: z.array(ScopeItemSchema).default([]),
|
||||
demandLines: z.array(EstimateDemandLineSchema).default([]),
|
||||
resourceSnapshots: z.array(ResourceCostSnapshotSchema).default([]),
|
||||
metrics: z.array(EstimateMetricSchema).default([]),
|
||||
exports: z.array(EstimateExportSchema).default([]),
|
||||
});
|
||||
|
||||
export const CreateEstimateSchema = z.object({
|
||||
projectId: z.string().optional(),
|
||||
name: z.string().min(1).max(500),
|
||||
opportunityId: z.string().max(200).optional(),
|
||||
baseCurrency: z.string().length(3).default("EUR"),
|
||||
status: z.nativeEnum(EstimateStatus).default(EstimateStatus.DRAFT),
|
||||
versionLabel: z.string().max(200).optional(),
|
||||
versionNotes: z.string().max(5_000).optional(),
|
||||
assumptions: z.array(EstimateAssumptionSchema).default([]),
|
||||
scopeItems: z.array(ScopeItemSchema).default([]),
|
||||
demandLines: z.array(EstimateDemandLineSchema).default([]),
|
||||
resourceSnapshots: z.array(ResourceCostSnapshotSchema).default([]),
|
||||
metrics: z.array(EstimateMetricSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateEstimateSchema = CreateEstimateSchema.partial();
|
||||
export const UpdateEstimateDraftSchema = CreateEstimateSchema.partial().extend({
|
||||
id: z.string(),
|
||||
assumptions: z.array(EstimateAssumptionSchema).default([]),
|
||||
scopeItems: z.array(ScopeItemSchema).default([]),
|
||||
demandLines: z.array(EstimateDemandLineSchema).default([]),
|
||||
resourceSnapshots: z.array(ResourceCostSnapshotSchema).default([]),
|
||||
metrics: z.array(EstimateMetricSchema).default([]),
|
||||
});
|
||||
|
||||
export const SubmitEstimateVersionSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
versionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ApproveEstimateVersionSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
versionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateEstimateRevisionSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
sourceVersionId: z.string().optional(),
|
||||
label: z.string().max(200).optional(),
|
||||
notes: z.string().max(5_000).optional(),
|
||||
});
|
||||
|
||||
export const CreateEstimateExportSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
versionId: z.string().optional(),
|
||||
format: z.nativeEnum(EstimateExportFormat),
|
||||
});
|
||||
|
||||
export const CreateEstimatePlanningHandoffSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
versionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CloneEstimateSchema = z.object({
|
||||
sourceEstimateId: z.string(),
|
||||
name: z.string().min(1).max(500).optional(),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const EffortUnitModeSchema = z.enum(["per_frame", "per_item", "flat"]);
|
||||
|
||||
export const EffortRuleSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
scopeType: z.string().min(1).max(100),
|
||||
discipline: z.string().min(1).max(200),
|
||||
chapter: z.string().max(200).optional(),
|
||||
unitMode: EffortUnitModeSchema,
|
||||
hoursPerUnit: z.number().min(0),
|
||||
description: z.string().max(1000).optional(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const CreateEffortRuleSetSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
isDefault: z.boolean().default(false),
|
||||
rules: z.array(EffortRuleSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateEffortRuleSetSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
rules: z.array(EffortRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export const ApplyEffortRulesSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
ruleSetId: z.string(),
|
||||
mode: z.enum(["replace", "append"]).default("replace"),
|
||||
});
|
||||
|
||||
export type CreateEffortRuleSetInput = z.infer<typeof CreateEffortRuleSetSchema>;
|
||||
export type UpdateEffortRuleSetInput = z.infer<typeof UpdateEffortRuleSetSchema>;
|
||||
export type ApplyEffortRulesInput = z.infer<typeof ApplyEffortRulesSchema>;
|
||||
|
||||
// ─── Experience Multipliers ──────────────────────────────────────────────────
|
||||
|
||||
export const ExperienceMultiplierRuleSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
chapter: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
level: z.string().max(100).optional(),
|
||||
costMultiplier: z.number().min(0).default(1.0),
|
||||
billMultiplier: z.number().min(0).default(1.0),
|
||||
shoringRatio: z.number().min(0).max(1).optional(),
|
||||
additionalEffortRatio: z.number().min(0).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const CreateExperienceMultiplierSetSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
isDefault: z.boolean().default(false),
|
||||
rules: z.array(ExperienceMultiplierRuleSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateExperienceMultiplierSetSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
rules: z.array(ExperienceMultiplierRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export const ApplyExperienceMultipliersSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
multiplierSetId: z.string(),
|
||||
});
|
||||
|
||||
export type CreateExperienceMultiplierSetInput = z.infer<typeof CreateExperienceMultiplierSetSchema>;
|
||||
export type UpdateExperienceMultiplierSetInput = z.infer<typeof UpdateExperienceMultiplierSetSchema>;
|
||||
export type ApplyExperienceMultipliersInput = z.infer<typeof ApplyExperienceMultipliersSchema>;
|
||||
|
||||
export const EstimateListFiltersSchema = z.object({
|
||||
projectId: z.string().optional(),
|
||||
status: z.nativeEnum(EstimateStatus).optional(),
|
||||
query: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const PhasingPatternSchema = z.enum([
|
||||
"even",
|
||||
"front_loaded",
|
||||
"back_loaded",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
export const GenerateWeeklyPhasingSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
pattern: PhasingPatternSchema.default("even"),
|
||||
});
|
||||
|
||||
export const UpdateWeeklyPhasingSchema = z.object({
|
||||
estimateId: z.string(),
|
||||
demandLineId: z.string(),
|
||||
weeklyHours: z.record(z.string(), z.number().min(0)),
|
||||
});
|
||||
|
||||
export type GenerateWeeklyPhasingInput = z.infer<typeof GenerateWeeklyPhasingSchema>;
|
||||
export type UpdateWeeklyPhasingInput = z.infer<typeof UpdateWeeklyPhasingSchema>;
|
||||
|
||||
export type CreateEstimateInput = z.infer<typeof CreateEstimateSchema>;
|
||||
export type UpdateEstimateInput = z.infer<typeof UpdateEstimateSchema>;
|
||||
export type UpdateEstimateDraftInput = z.infer<typeof UpdateEstimateDraftSchema>;
|
||||
export type EstimateListFilters = z.infer<typeof EstimateListFiltersSchema>;
|
||||
export type SubmitEstimateVersionInput = z.infer<
|
||||
typeof SubmitEstimateVersionSchema
|
||||
>;
|
||||
export type ApproveEstimateVersionInput = z.infer<
|
||||
typeof ApproveEstimateVersionSchema
|
||||
>;
|
||||
export type CreateEstimateRevisionInput = z.infer<
|
||||
typeof CreateEstimateRevisionSchema
|
||||
>;
|
||||
export type CreateEstimateExportInput = z.infer<
|
||||
typeof CreateEstimateExportSchema
|
||||
>;
|
||||
export type CreateEstimatePlanningHandoffInput = z.infer<
|
||||
typeof CreateEstimatePlanningHandoffSchema
|
||||
>;
|
||||
export type CloneEstimateInput = z.infer<typeof CloneEstimateSchema>;
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from "./resource.schema.js";
|
||||
export * from "./project.schema.js";
|
||||
export * from "./allocation.schema.js";
|
||||
export * from "./blueprint.schema.js";
|
||||
export * from "./vacation.schema.js";
|
||||
export * from "./role.schema.js";
|
||||
export * from "./dashboard.schema.js";
|
||||
export * from "./estimate.schema.js";
|
||||
export * from "./country.schema.js";
|
||||
export * from "./org-unit.schema.js";
|
||||
export * from "./utilization-category.schema.js";
|
||||
export * from "./client.schema.js";
|
||||
export * from "./management-level.schema.js";
|
||||
export * from "./rate-card.schema.js";
|
||||
export * from "./dispo-import.schema.js";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CreateManagementLevelGroupSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
targetPercentage: z.number().min(0).max(1),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export const UpdateManagementLevelGroupSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
targetPercentage: z.number().min(0).max(1).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const CreateManagementLevelSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
groupId: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateManagementLevelSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
groupId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateManagementLevelGroupInput = z.infer<typeof CreateManagementLevelGroupSchema>;
|
||||
export type UpdateManagementLevelGroupInput = z.infer<typeof UpdateManagementLevelGroupSchema>;
|
||||
export type CreateManagementLevelInput = z.infer<typeof CreateManagementLevelSchema>;
|
||||
export type UpdateManagementLevelInput = z.infer<typeof UpdateManagementLevelSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CreateOrgUnitSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
shortName: z.string().max(50).optional(),
|
||||
level: z.number().int().min(5).max(7),
|
||||
parentId: z.string().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export const UpdateOrgUnitSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
shortName: z.string().max(50).nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateOrgUnitInput = z.infer<typeof CreateOrgUnitSchema>;
|
||||
export type UpdateOrgUnitInput = z.infer<typeof UpdateOrgUnitSchema>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { AllocationType, OrderType, ProjectStatus } from "../types/enums.js";
|
||||
|
||||
export const StaffingRequirementSchema = z.object({
|
||||
id: z.string().uuid().default(() => crypto.randomUUID()),
|
||||
role: z.string().min(1).max(200),
|
||||
requiredSkills: z.array(z.string()),
|
||||
preferredSkills: z.array(z.string()).optional(),
|
||||
hoursPerDay: z.number().min(0).max(24),
|
||||
headcount: z.number().int().min(1),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
chapter: z.string().optional(),
|
||||
});
|
||||
|
||||
// Base object schema — used for .partial() in UpdateProjectSchema
|
||||
export const CreateProjectBaseSchema = z.object({
|
||||
shortCode: z.string().min(1).max(20).regex(/^[A-Z0-9_-]+$/, "Must be uppercase alphanumeric"),
|
||||
name: z.string().min(1).max(500),
|
||||
orderType: z.nativeEnum(OrderType),
|
||||
allocationType: z.nativeEnum(AllocationType),
|
||||
winProbability: z.number().int().min(0).max(100).default(100),
|
||||
budgetCents: z.number().int().min(0),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
staffingReqs: z.array(StaffingRequirementSchema).default([]),
|
||||
dynamicFields: z.record(z.string(), z.unknown()).default({}),
|
||||
blueprintId: z.string().optional(),
|
||||
status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT),
|
||||
responsiblePerson: z.string().max(200).optional(),
|
||||
utilizationCategoryId: z.string().optional(),
|
||||
clientId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Full schema with date-range validation
|
||||
export const CreateProjectSchema = CreateProjectBaseSchema.refine(
|
||||
(data) => data.endDate >= data.startDate,
|
||||
{ message: "End date must be after start date", path: ["endDate"] },
|
||||
);
|
||||
|
||||
export const UpdateProjectSchema = CreateProjectBaseSchema.partial();
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── Rate Card Line ──────────────────────────────────────────────────────────
|
||||
|
||||
export const CreateRateCardLineSchema = z.object({
|
||||
roleId: z.string().optional(),
|
||||
chapter: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
seniority: z.string().max(100).optional(),
|
||||
workType: z.string().max(100).optional(),
|
||||
serviceGroup: z.string().max(100).optional(),
|
||||
costRateCents: z.number().int().min(0),
|
||||
billRateCents: z.number().int().min(0).optional(),
|
||||
machineRateCents: z.number().int().min(0).optional(),
|
||||
attributes: z.record(z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const UpdateRateCardLineSchema = CreateRateCardLineSchema.partial();
|
||||
|
||||
export type CreateRateCardLineInput = z.infer<typeof CreateRateCardLineSchema>;
|
||||
export type UpdateRateCardLineInput = z.infer<typeof UpdateRateCardLineSchema>;
|
||||
|
||||
// ─── Rate Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const CreateRateCardSchema = z.object({
|
||||
name: z.string().min(1).max(300),
|
||||
currency: z.string().length(3).default("EUR"),
|
||||
effectiveFrom: z.coerce.date().optional(),
|
||||
effectiveTo: z.coerce.date().optional(),
|
||||
source: z.string().max(200).optional(),
|
||||
clientId: z.string().optional(),
|
||||
lines: z.array(CreateRateCardLineSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateRateCardSchema = z.object({
|
||||
name: z.string().min(1).max(300).optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
effectiveFrom: z.coerce.date().nullable().optional(),
|
||||
effectiveTo: z.coerce.date().nullable().optional(),
|
||||
source: z.string().max(200).nullable().optional(),
|
||||
clientId: z.string().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateRateCardInput = z.infer<typeof CreateRateCardSchema>;
|
||||
export type UpdateRateCardInput = z.infer<typeof UpdateRateCardSchema>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
import { ResourceType } from "../types/enums.js";
|
||||
|
||||
export const WeekdayAvailabilitySchema = z.object({
|
||||
monday: z.number().min(0).max(24),
|
||||
tuesday: z.number().min(0).max(24),
|
||||
wednesday: z.number().min(0).max(24),
|
||||
thursday: z.number().min(0).max(24),
|
||||
friday: z.number().min(0).max(24),
|
||||
saturday: z.number().min(0).max(24).optional(),
|
||||
sunday: z.number().min(0).max(24).optional(),
|
||||
});
|
||||
|
||||
export const SkillEntrySchema = z.object({
|
||||
skill: z.string().min(1).max(100),
|
||||
category: z.string().max(100).optional(),
|
||||
proficiency: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]),
|
||||
yearsExperience: z.number().min(0).max(50).optional(),
|
||||
certified: z.boolean().optional(),
|
||||
isMainSkill: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CreateResourceSchema = z.object({
|
||||
eid: z.string().min(1).max(50),
|
||||
displayName: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
chapter: z.string().max(100).optional(),
|
||||
lcrCents: z.number().int().min(0),
|
||||
ucrCents: z.number().int().min(0),
|
||||
currency: z.string().length(3).default("EUR"),
|
||||
chargeabilityTarget: z.number().min(0).max(100).default(80),
|
||||
availability: WeekdayAvailabilitySchema.default({
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
}),
|
||||
skills: z.array(SkillEntrySchema).default([]),
|
||||
dynamicFields: z.record(z.string(), z.unknown()).default({}),
|
||||
blueprintId: z.string().optional(),
|
||||
portfolioUrl: z.string().url().optional().or(z.literal("")),
|
||||
roleId: z.string().optional(),
|
||||
postalCode: z.string().max(10).optional(),
|
||||
federalState: z.string().max(5).optional(),
|
||||
countryId: z.string().optional(),
|
||||
metroCityId: z.string().optional(),
|
||||
orgUnitId: z.string().optional(),
|
||||
managementLevelGroupId: z.string().optional(),
|
||||
managementLevelId: z.string().optional(),
|
||||
resourceType: z.nativeEnum(ResourceType).optional(),
|
||||
chgResponsibility: z.boolean().optional(),
|
||||
rolledOff: z.boolean().optional(),
|
||||
departed: z.boolean().optional(),
|
||||
enterpriseId: z.string().max(100).optional(),
|
||||
clientUnitId: z.string().optional(),
|
||||
fte: z.number().min(0.01).max(1).optional(),
|
||||
});
|
||||
|
||||
export const UpdateResourceSchema = CreateResourceSchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateResourceInput = z.infer<typeof CreateResourceSchema>;
|
||||
export type UpdateResourceInput = z.infer<typeof UpdateResourceSchema>;
|
||||
export type WeekdayAvailabilityInput = z.infer<typeof WeekdayAvailabilitySchema>;
|
||||
export type SkillEntryInput = z.infer<typeof SkillEntrySchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CreateRoleSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
||||
});
|
||||
|
||||
export const UpdateRoleSchema = CreateRoleSchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ResourceRoleSchema = z.object({
|
||||
roleId: z.string(),
|
||||
isPrimary: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CreateRoleInput = z.infer<typeof CreateRoleSchema>;
|
||||
export type UpdateRoleInput = z.infer<typeof UpdateRoleSchema>;
|
||||
export type ResourceRoleInput = z.infer<typeof ResourceRoleSchema>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const CreateUtilizationCategorySchema = z.object({
|
||||
code: z.string().min(1).max(20),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(500).optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UpdateUtilizationCategorySchema = z.object({
|
||||
code: z.string().min(1).max(20).optional(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateUtilizationCategoryInput = z.infer<typeof CreateUtilizationCategorySchema>;
|
||||
export type UpdateUtilizationCategoryInput = z.infer<typeof UpdateUtilizationCategorySchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import { VacationType } from "../types/enums.js";
|
||||
|
||||
export const CreateVacationSchema = z
|
||||
.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().max(500).optional(),
|
||||
})
|
||||
.refine((d) => d.endDate >= d.startDate, {
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
});
|
||||
|
||||
export type CreateVacationInput = z.infer<typeof CreateVacationSchema>;
|
||||
|
||||
export const UpdateVacationStatusSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.enum(["APPROVED", "REJECTED", "CANCELLED"]),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export type UpdateVacationStatusInput = z.infer<typeof UpdateVacationStatusSchema>;
|
||||
@@ -0,0 +1,179 @@
|
||||
import { type AllocationStatus, type RecurrenceFrequency } from "./enums.js";
|
||||
|
||||
export interface RecurrencePattern {
|
||||
frequency: RecurrenceFrequency;
|
||||
weekdays?: number[]; // 0=Sun..6=Sat — for WEEKLY/BIWEEKLY
|
||||
monthDay?: number; // 1-31 — for MONTHLY
|
||||
hoursPerDay?: number; // override hours on recurring days (for CUSTOM)
|
||||
interval?: number; // week interval (1=weekly, 2=biweekly)
|
||||
startDate?: string; // ISO date — recurrence start (defaults to allocation start)
|
||||
endDate?: string; // ISO date — recurrence end (defaults to allocation end)
|
||||
}
|
||||
|
||||
export interface AllocationMetadata {
|
||||
includeSaturday?: boolean;
|
||||
recurrence?: RecurrencePattern;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DemandRequirementMetadata {
|
||||
suggestedResourceId?: string;
|
||||
estimateHandoff?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AssignmentMetadata extends AllocationMetadata {
|
||||
estimateHandoff?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Allocation {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
resourceId: string | null;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
isPlaceholder: boolean;
|
||||
headcount: number;
|
||||
dailyCostCents: number;
|
||||
status: AllocationStatus;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AllocationResourceSummary {
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
lcrCents: number;
|
||||
chapter?: string | null;
|
||||
availability?: unknown;
|
||||
}
|
||||
|
||||
export interface AllocationProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
status?: string;
|
||||
endDate?: Date | string;
|
||||
startDate?: Date | string;
|
||||
orderType?: string;
|
||||
budgetCents?: number;
|
||||
winProbability?: number;
|
||||
staffingReqs?: unknown;
|
||||
responsiblePerson?: string | null;
|
||||
}
|
||||
|
||||
export interface AllocationRoleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface DemandRequirementRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
headcount: number;
|
||||
status: AllocationStatus;
|
||||
metadata: DemandRequirementMetadata;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AssignmentRecord {
|
||||
id: string;
|
||||
demandRequirementId?: string | null;
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
dailyCostCents: number;
|
||||
status: AllocationStatus;
|
||||
metadata: AssignmentMetadata;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DemandRequirementRecordWithDetails extends DemandRequirementRecord {
|
||||
project?: AllocationProjectSummary;
|
||||
roleEntity?: AllocationRoleSummary | null;
|
||||
assignments?: AssignmentRecordWithDetails[];
|
||||
}
|
||||
|
||||
export interface AssignmentRecordWithDetails extends AssignmentRecord {
|
||||
resource?: AllocationResourceSummary | null;
|
||||
project?: AllocationProjectSummary;
|
||||
roleEntity?: AllocationRoleSummary | null;
|
||||
demandRequirement?: Pick<
|
||||
DemandRequirementRecord,
|
||||
"id" | "projectId" | "startDate" | "endDate" | "hoursPerDay" | "percentage" | "role" | "roleId" | "headcount" | "status"
|
||||
> | null;
|
||||
}
|
||||
|
||||
export interface AllocationWithDetails extends Allocation {
|
||||
resource?: AllocationResourceSummary | null;
|
||||
project?: AllocationProjectSummary;
|
||||
roleEntity?: AllocationRoleSummary | null;
|
||||
}
|
||||
|
||||
export interface AllocationLike {
|
||||
id: string;
|
||||
entityId?: string;
|
||||
resourceId: string | null;
|
||||
projectId: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
role: string | null;
|
||||
roleId: string | null;
|
||||
isPlaceholder: boolean;
|
||||
headcount: number;
|
||||
dailyCostCents: number;
|
||||
status: string;
|
||||
metadata: unknown;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
resource?: AllocationResourceSummary | null;
|
||||
project?: AllocationProjectSummary;
|
||||
roleEntity?: AllocationRoleSummary | null;
|
||||
}
|
||||
|
||||
export type DemandRequirement<TAllocation extends AllocationLike = AllocationWithDetails> =
|
||||
Omit<TAllocation, "resourceId" | "isPlaceholder"> & {
|
||||
kind: "demand";
|
||||
sourceAllocationId: string;
|
||||
resourceId: null;
|
||||
isPlaceholder: true;
|
||||
requestedHeadcount: number;
|
||||
unfilledHeadcount: number;
|
||||
};
|
||||
|
||||
export type Assignment<TAllocation extends AllocationLike = AllocationWithDetails> =
|
||||
Omit<TAllocation, "resourceId" | "isPlaceholder"> & {
|
||||
kind: "assignment";
|
||||
sourceAllocationId: string;
|
||||
resourceId: string;
|
||||
isPlaceholder: false;
|
||||
};
|
||||
|
||||
export interface AllocationReadModel<TAllocation extends AllocationLike = AllocationWithDetails> {
|
||||
allocations: TAllocation[];
|
||||
demands: DemandRequirement<TAllocation>[];
|
||||
assignments: Assignment<TAllocation>[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
parentId?: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ClientWithChildren extends Client {
|
||||
children: Client[];
|
||||
}
|
||||
|
||||
export interface ClientTree extends Client {
|
||||
children: ClientTree[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type ViewKey =
|
||||
| "resources"
|
||||
| "projects"
|
||||
| "allocations"
|
||||
| "vacations"
|
||||
| "roles"
|
||||
| "users"
|
||||
| "blueprints";
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
defaultVisible: boolean;
|
||||
hideable: boolean;
|
||||
sortable?: boolean;
|
||||
width?: number;
|
||||
isCustom?: boolean;
|
||||
fieldType?: string;
|
||||
}
|
||||
|
||||
export interface ViewPreferences {
|
||||
visible: string[];
|
||||
/** Persisted column sort state. null / absent = no saved sort. */
|
||||
sort?: { field: string; dir: "asc" | "desc" };
|
||||
/** Persisted row order as array of row IDs. Empty = natural order. */
|
||||
rowOrder?: string[];
|
||||
}
|
||||
|
||||
export type ColumnPreferences = Partial<Record<ViewKey, ViewPreferences>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface Country {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dailyWorkingHours: number;
|
||||
scheduleRules?: SpainScheduleRule | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface MetroCity {
|
||||
id: string;
|
||||
name: string;
|
||||
countryId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CountryWithCities extends Country {
|
||||
metroCities: MetroCity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spain-style variable daily working hours schedule.
|
||||
* Fridays always use `fridayHours`.
|
||||
* Mon-Thu during summer period use `summerHours`, otherwise `regularHours`.
|
||||
*/
|
||||
export interface SpainScheduleRule {
|
||||
type: "spain";
|
||||
fridayHours: number;
|
||||
summerPeriod: { from: string; to: string }; // "MM-DD" format
|
||||
summerHours: number;
|
||||
regularHours: number;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { ProjectStatus } from "./enums.js";
|
||||
|
||||
export const DASHBOARD_LAYOUT_VERSION = 2;
|
||||
export const DASHBOARD_GRID_COLUMNS = 12;
|
||||
|
||||
export interface DashboardWidgetSize {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface StatCardsWidgetConfig {}
|
||||
|
||||
export interface ResourceTableWidgetConfig {
|
||||
chapter?: string;
|
||||
}
|
||||
|
||||
export interface ProjectTableWidgetConfig {
|
||||
search?: string;
|
||||
status?: ProjectStatus;
|
||||
}
|
||||
|
||||
export interface PeakTimesWidgetConfig {
|
||||
granularity?: "week" | "month";
|
||||
groupBy?: "project" | "chapter" | "resource";
|
||||
}
|
||||
|
||||
export interface DemandWidgetConfig {
|
||||
groupBy?: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
export interface TopValueResourcesWidgetConfig {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ChargeabilityOverviewWidgetConfig {
|
||||
topN?: number;
|
||||
watchlistThreshold?: number;
|
||||
}
|
||||
|
||||
export interface DashboardWidgetConfigMap {
|
||||
"stat-cards": StatCardsWidgetConfig;
|
||||
"resource-table": ResourceTableWidgetConfig;
|
||||
"project-table": ProjectTableWidgetConfig;
|
||||
"peak-times-chart": PeakTimesWidgetConfig;
|
||||
"demand-view": DemandWidgetConfig;
|
||||
"top-value-resources": TopValueResourcesWidgetConfig;
|
||||
"chargeability-overview": ChargeabilityOverviewWidgetConfig;
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGET_TYPES = [
|
||||
"stat-cards",
|
||||
"resource-table",
|
||||
"project-table",
|
||||
"peak-times-chart",
|
||||
"demand-view",
|
||||
"top-value-resources",
|
||||
"chargeability-overview",
|
||||
] as const;
|
||||
|
||||
export type DashboardWidgetType = (typeof DASHBOARD_WIDGET_TYPES)[number];
|
||||
export type DashboardWidgetConfig = DashboardWidgetConfigMap[DashboardWidgetType];
|
||||
|
||||
export interface DashboardWidgetCatalogEntry<T extends DashboardWidgetType = DashboardWidgetType> {
|
||||
type: T;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
defaultSize: DashboardWidgetSize;
|
||||
minSize: DashboardWidgetSize;
|
||||
defaultConfig: DashboardWidgetConfigMap[T];
|
||||
}
|
||||
|
||||
export interface DashboardWidgetInstance<T extends DashboardWidgetType = DashboardWidgetType> {
|
||||
id: string;
|
||||
type: T;
|
||||
title?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW: number;
|
||||
minH: number;
|
||||
config: DashboardWidgetConfigMap[T];
|
||||
}
|
||||
|
||||
export interface DashboardLayoutConfig {
|
||||
version: number;
|
||||
widgets: DashboardWidgetInstance[];
|
||||
gridCols: number;
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGET_CATALOG = [
|
||||
{
|
||||
type: "stat-cards",
|
||||
label: "Overview Stats",
|
||||
description: "Key metrics: total resources, active projects, allocations, budget utilization",
|
||||
icon: "📊",
|
||||
defaultSize: { w: 12, h: 3 },
|
||||
minSize: { w: 6, h: 2 },
|
||||
defaultConfig: {},
|
||||
},
|
||||
{
|
||||
type: "resource-table",
|
||||
label: "Resource Table",
|
||||
description: "Filterable list of EIDs with utilization and chargeability",
|
||||
icon: "👥",
|
||||
defaultSize: { w: 8, h: 6 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
},
|
||||
{
|
||||
type: "project-table",
|
||||
label: "Project Overview",
|
||||
description: "Projects with costs, person days, and timeline",
|
||||
icon: "📋",
|
||||
defaultSize: { w: 8, h: 6 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
},
|
||||
{
|
||||
type: "peak-times-chart",
|
||||
label: "Peak Times",
|
||||
description: "Booked hours vs capacity over time",
|
||||
icon: "📈",
|
||||
defaultSize: { w: 8, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {
|
||||
granularity: "month",
|
||||
groupBy: "project",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "demand-view",
|
||||
label: "Demand View",
|
||||
description: "Staffing demand vs supply by project, person, or chapter",
|
||||
icon: "🔍",
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {
|
||||
groupBy: "project",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "top-value-resources",
|
||||
label: "Top Value Resources",
|
||||
description: "Leaderboard of resources ranked by price/quality value score",
|
||||
icon: "★",
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "chargeability-overview",
|
||||
label: "Chargeability Overview",
|
||||
description: "Top-list and watchlist by actual chargeability this month",
|
||||
icon: "⚡",
|
||||
defaultSize: { w: 6, h: 8 },
|
||||
minSize: { w: 4, h: 6 },
|
||||
defaultConfig: {
|
||||
topN: 10,
|
||||
watchlistThreshold: 15,
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly DashboardWidgetCatalogEntry[];
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
AllocationType,
|
||||
ImportBatchStatus,
|
||||
OrderType,
|
||||
ResourceType,
|
||||
StagedRecordStatus,
|
||||
DispoImportSourceKind,
|
||||
DispoStagedRecordType,
|
||||
VacationType,
|
||||
} from "./enums.js";
|
||||
|
||||
export interface ImportBatch {
|
||||
id: string;
|
||||
sourceSystem: string;
|
||||
status: ImportBatchStatus;
|
||||
referenceSourceFile?: string | null;
|
||||
planningSourceFile?: string | null;
|
||||
chargeabilitySourceFile?: string | null;
|
||||
notes?: string | null;
|
||||
summary: Record<string, unknown>;
|
||||
startedAt?: Date | null;
|
||||
stagedAt?: Date | null;
|
||||
approvedAt?: Date | null;
|
||||
committedAt?: Date | null;
|
||||
failedAt?: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface StagedTrace {
|
||||
importBatchId: string;
|
||||
status: StagedRecordStatus;
|
||||
sourceKind: DispoImportSourceKind;
|
||||
sourceWorkbook: string;
|
||||
sourceSheet: string;
|
||||
sourceRow: number;
|
||||
sourceColumn?: string | null;
|
||||
warnings: string[];
|
||||
errorMessage?: string | null;
|
||||
rawPayload: unknown;
|
||||
normalizedData: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface StagedResource extends StagedTrace {
|
||||
id: string;
|
||||
canonicalExternalId: string;
|
||||
enterpriseId?: string | null;
|
||||
eid?: string | null;
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
chapter?: string | null;
|
||||
chapterCode?: string | null;
|
||||
managementLevelGroupName?: string | null;
|
||||
managementLevelName?: string | null;
|
||||
countryCode?: string | null;
|
||||
metroCityName?: string | null;
|
||||
clientUnitName?: string | null;
|
||||
resourceType?: ResourceType | null;
|
||||
chargeabilityTarget?: number | null;
|
||||
fte?: number | null;
|
||||
lcrCents?: number | null;
|
||||
ucrCents?: number | null;
|
||||
availability?: Record<string, unknown> | null;
|
||||
roleTokens: string[];
|
||||
}
|
||||
|
||||
export interface StagedClient extends StagedTrace {
|
||||
id: string;
|
||||
clientCode?: string | null;
|
||||
parentClientCode?: string | null;
|
||||
name: string;
|
||||
sortOrder?: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface StagedProject extends StagedTrace {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
shortCode?: string | null;
|
||||
name?: string | null;
|
||||
clientCode?: string | null;
|
||||
utilizationCategoryCode?: string | null;
|
||||
orderType?: OrderType | null;
|
||||
allocationType?: AllocationType | null;
|
||||
winProbability?: number | null;
|
||||
isInternal: boolean;
|
||||
isTbd: boolean;
|
||||
startDate?: Date | null;
|
||||
endDate?: Date | null;
|
||||
}
|
||||
|
||||
export interface StagedAssignment extends StagedTrace {
|
||||
id: string;
|
||||
resourceExternalId: string;
|
||||
projectKey?: string | null;
|
||||
assignmentDate?: Date | null;
|
||||
startDate?: Date | null;
|
||||
endDate?: Date | null;
|
||||
hoursPerDay?: number | null;
|
||||
percentage?: number | null;
|
||||
slotFraction?: number | null;
|
||||
roleToken?: string | null;
|
||||
roleName?: string | null;
|
||||
chapterToken?: string | null;
|
||||
utilizationCategoryCode?: string | null;
|
||||
winProbability?: number | null;
|
||||
isInternal: boolean;
|
||||
isUnassigned: boolean;
|
||||
isTbd: boolean;
|
||||
}
|
||||
|
||||
export interface StagedVacation extends StagedTrace {
|
||||
id: string;
|
||||
resourceExternalId: string;
|
||||
vacationType: VacationType;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
note?: string | null;
|
||||
holidayName?: string | null;
|
||||
isHalfDay: boolean;
|
||||
halfDayPart?: string | null;
|
||||
isPublicHoliday: boolean;
|
||||
}
|
||||
|
||||
export interface StagedAvailabilityRule extends StagedTrace {
|
||||
id: string;
|
||||
resourceExternalId: string;
|
||||
ruleType: string;
|
||||
weekday?: number | null;
|
||||
effectiveStartDate?: Date | null;
|
||||
effectiveEndDate?: Date | null;
|
||||
availableHours?: number | null;
|
||||
percentage?: number | null;
|
||||
isResolved: boolean;
|
||||
}
|
||||
|
||||
export interface StagedUnresolvedRecord {
|
||||
id: string;
|
||||
importBatchId: string;
|
||||
status: StagedRecordStatus;
|
||||
sourceKind: DispoImportSourceKind;
|
||||
sourceWorkbook: string;
|
||||
sourceSheet: string;
|
||||
sourceRow: number;
|
||||
sourceColumn?: string | null;
|
||||
recordType: DispoStagedRecordType;
|
||||
resourceExternalId?: string | null;
|
||||
projectKey?: string | null;
|
||||
message: string;
|
||||
resolutionHint?: string | null;
|
||||
warnings: string[];
|
||||
rawPayload: unknown;
|
||||
normalizedData: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type FieldType } from "./enums.js";
|
||||
|
||||
export interface BlueprintFieldDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: unknown;
|
||||
options?: FieldOption[];
|
||||
validation?: FieldValidation;
|
||||
order: number;
|
||||
group?: string;
|
||||
showInList?: boolean;
|
||||
showInDetail?: boolean;
|
||||
isFilterable?: boolean;
|
||||
isSortable?: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface FieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface FieldValidation {
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type DynamicFields = Record<string, unknown>;
|
||||
|
||||
export interface BlueprintValidationRule {
|
||||
field: string;
|
||||
rule: "required_if" | "unique" | "min" | "max";
|
||||
params?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export interface ShiftValidationResult {
|
||||
valid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
costImpact: CostImpact;
|
||||
conflictDetails: ConflictDetail[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
resourceId?: string;
|
||||
allocationId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
resourceId?: string;
|
||||
}
|
||||
|
||||
export interface CostImpact {
|
||||
currentTotalCents: number;
|
||||
newTotalCents: number;
|
||||
deltaCents: number;
|
||||
budgetCents: number;
|
||||
budgetUtilizationBefore: number;
|
||||
budgetUtilizationAfter: number;
|
||||
wouldExceedBudget: boolean;
|
||||
}
|
||||
|
||||
export interface ConflictDetail {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
conflictType: "availability" | "overlap" | "capacity";
|
||||
days: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AllocationCalculationInput {
|
||||
lcrCents: number;
|
||||
hoursPerDay: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
availability: import("./resource.js").WeekdayAvailability;
|
||||
/** When false (default), Saturday hours are ignored even if resource has availability.saturday set */
|
||||
includeSaturday?: boolean;
|
||||
/** Recurrence pattern — when set, only matching days in the range accumulate hours */
|
||||
recurrence?: import("./allocation.js").RecurrencePattern;
|
||||
/** APPROVED vacation dates — these days are blocked regardless of other settings */
|
||||
vacationDates?: Date[];
|
||||
}
|
||||
|
||||
export interface AllocationCalculationResult {
|
||||
workingDays: number;
|
||||
totalHours: number;
|
||||
totalCostCents: number;
|
||||
dailyCostCents: number;
|
||||
dailyBreakdown: DailyBreakdown[];
|
||||
}
|
||||
|
||||
export interface DailyBreakdown {
|
||||
date: Date;
|
||||
isWorkday: boolean;
|
||||
hours: number;
|
||||
costCents: number;
|
||||
}
|
||||
|
||||
export interface BudgetStatus {
|
||||
budgetCents: number;
|
||||
allocatedCents: number;
|
||||
confirmedCents: number;
|
||||
proposedCents: number;
|
||||
remainingCents: number;
|
||||
utilizationPercent: number;
|
||||
winProbabilityWeightedCents: number;
|
||||
warnings: BudgetWarning[];
|
||||
}
|
||||
|
||||
export interface BudgetWarning {
|
||||
level: "info" | "warning" | "critical";
|
||||
code: string;
|
||||
message: string;
|
||||
thresholdPercent: number;
|
||||
currentPercent: number;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export enum SystemRole {
|
||||
ADMIN = "ADMIN",
|
||||
MANAGER = "MANAGER",
|
||||
CONTROLLER = "CONTROLLER",
|
||||
USER = "USER",
|
||||
VIEWER = "VIEWER",
|
||||
}
|
||||
|
||||
export enum OrderType {
|
||||
BD = "BD",
|
||||
CHARGEABLE = "CHARGEABLE",
|
||||
INTERNAL = "INTERNAL",
|
||||
OVERHEAD = "OVERHEAD",
|
||||
}
|
||||
|
||||
export enum AllocationType {
|
||||
INT = "INT",
|
||||
EXT = "EXT",
|
||||
}
|
||||
|
||||
export enum AllocationStatus {
|
||||
PROPOSED = "PROPOSED",
|
||||
CONFIRMED = "CONFIRMED",
|
||||
ACTIVE = "ACTIVE",
|
||||
COMPLETED = "COMPLETED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
|
||||
export enum BlueprintTarget {
|
||||
RESOURCE = "RESOURCE",
|
||||
PROJECT = "PROJECT",
|
||||
}
|
||||
|
||||
export enum FieldType {
|
||||
TEXT = "TEXT",
|
||||
TEXTAREA = "TEXTAREA",
|
||||
NUMBER = "NUMBER",
|
||||
BOOLEAN = "BOOLEAN",
|
||||
DATE = "DATE",
|
||||
SELECT = "SELECT",
|
||||
MULTI_SELECT = "MULTI_SELECT",
|
||||
URL = "URL",
|
||||
EMAIL = "EMAIL",
|
||||
}
|
||||
|
||||
export enum ProjectStatus {
|
||||
DRAFT = "DRAFT",
|
||||
ACTIVE = "ACTIVE",
|
||||
ON_HOLD = "ON_HOLD",
|
||||
COMPLETED = "COMPLETED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
|
||||
export enum EstimateStatus {
|
||||
DRAFT = "DRAFT",
|
||||
IN_REVIEW = "IN_REVIEW",
|
||||
APPROVED = "APPROVED",
|
||||
ARCHIVED = "ARCHIVED",
|
||||
}
|
||||
|
||||
export enum EstimateVersionStatus {
|
||||
WORKING = "WORKING",
|
||||
BASELINE = "BASELINE",
|
||||
SUBMITTED = "SUBMITTED",
|
||||
APPROVED = "APPROVED",
|
||||
SUPERSEDED = "SUPERSEDED",
|
||||
}
|
||||
|
||||
export enum EstimateExportFormat {
|
||||
XLSX = "XLSX",
|
||||
CSV = "CSV",
|
||||
JSON = "JSON",
|
||||
SAP = "SAP",
|
||||
MMP = "MMP",
|
||||
}
|
||||
|
||||
export enum AuditAction {
|
||||
CREATE = "CREATE",
|
||||
UPDATE = "UPDATE",
|
||||
DELETE = "DELETE",
|
||||
SHIFT = "SHIFT",
|
||||
IMPORT = "IMPORT",
|
||||
}
|
||||
|
||||
export enum RecurrenceFrequency {
|
||||
WEEKLY = "WEEKLY",
|
||||
BIWEEKLY = "BIWEEKLY",
|
||||
MONTHLY = "MONTHLY",
|
||||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
export enum VacationType {
|
||||
ANNUAL = "ANNUAL",
|
||||
SICK = "SICK",
|
||||
PUBLIC_HOLIDAY = "PUBLIC_HOLIDAY",
|
||||
OTHER = "OTHER",
|
||||
}
|
||||
|
||||
export enum ResourceType {
|
||||
EMPLOYEE = "EMPLOYEE",
|
||||
FREELANCER = "FREELANCER",
|
||||
APPRENTICE = "APPRENTICE",
|
||||
INTERN = "INTERN",
|
||||
STUDENT = "STUDENT",
|
||||
}
|
||||
|
||||
export enum VacationStatus {
|
||||
PENDING = "PENDING",
|
||||
APPROVED = "APPROVED",
|
||||
REJECTED = "REJECTED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
|
||||
export enum ImportBatchStatus {
|
||||
DRAFT = "DRAFT",
|
||||
STAGING = "STAGING",
|
||||
STAGED = "STAGED",
|
||||
REVIEW_READY = "REVIEW_READY",
|
||||
APPROVED = "APPROVED",
|
||||
COMMITTING = "COMMITTING",
|
||||
COMMITTED = "COMMITTED",
|
||||
FAILED = "FAILED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
|
||||
export enum StagedRecordStatus {
|
||||
PARSED = "PARSED",
|
||||
NORMALIZED = "NORMALIZED",
|
||||
UNRESOLVED = "UNRESOLVED",
|
||||
APPROVED = "APPROVED",
|
||||
REJECTED = "REJECTED",
|
||||
COMMITTED = "COMMITTED",
|
||||
FAILED = "FAILED",
|
||||
}
|
||||
|
||||
export enum DispoImportSourceKind {
|
||||
REFERENCE = "REFERENCE",
|
||||
PLANNING = "PLANNING",
|
||||
CHARGEABILITY = "CHARGEABILITY",
|
||||
ROSTER = "ROSTER",
|
||||
}
|
||||
|
||||
export enum DispoStagedRecordType {
|
||||
RESOURCE = "RESOURCE",
|
||||
CLIENT = "CLIENT",
|
||||
PROJECT = "PROJECT",
|
||||
ASSIGNMENT = "ASSIGNMENT",
|
||||
VACATION = "VACATION",
|
||||
AVAILABILITY_RULE = "AVAILABILITY_RULE",
|
||||
UNRESOLVED = "UNRESOLVED",
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type {
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "./enums.js";
|
||||
|
||||
export type EstimateDemandLineRateMode = "resource" | "manual";
|
||||
|
||||
export interface EstimateDemandLineCalculationMetadata {
|
||||
costRateMode: EstimateDemandLineRateMode;
|
||||
billRateMode: EstimateDemandLineRateMode;
|
||||
totalMode: "computed";
|
||||
liveCostRateCents?: number | null;
|
||||
liveBillRateCents?: number | null;
|
||||
liveCurrency?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateDemandLineMetadata extends Record<string, unknown> {
|
||||
calculation?: EstimateDemandLineCalculationMetadata;
|
||||
}
|
||||
|
||||
export interface Estimate {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
name: string;
|
||||
opportunityId?: string | null;
|
||||
baseCurrency: string;
|
||||
status: EstimateStatus;
|
||||
latestVersionNumber: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateVersion {
|
||||
id: string;
|
||||
estimateId: string;
|
||||
versionNumber: number;
|
||||
label?: string | null;
|
||||
status: EstimateVersionStatus;
|
||||
notes?: string | null;
|
||||
lockedAt?: Date | null;
|
||||
projectSnapshot: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateAssumption {
|
||||
id: string;
|
||||
estimateVersionId: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueType: string;
|
||||
value: unknown;
|
||||
sortOrder: number;
|
||||
notes?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ScopeItem {
|
||||
id: string;
|
||||
estimateVersionId: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
packageCode?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scene?: string | null;
|
||||
page?: string | null;
|
||||
location?: string | null;
|
||||
assumptionCategory?: string | null;
|
||||
technicalSpec: Record<string, unknown>;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
unitMode?: string | null;
|
||||
internalComments?: string | null;
|
||||
externalComments?: string | null;
|
||||
sortOrder: number;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateDemandLine {
|
||||
id: string;
|
||||
estimateVersionId: string;
|
||||
scopeItemId?: string | null;
|
||||
roleId?: string | null;
|
||||
resourceId?: string | null;
|
||||
lineType: string;
|
||||
name: string;
|
||||
chapter?: string | null;
|
||||
hours: number;
|
||||
days?: number | null;
|
||||
fte?: number | null;
|
||||
rateSource?: string | null;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
currency: string;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
monthlySpread: Record<string, number>;
|
||||
staffingAttributes: Record<string, unknown>;
|
||||
metadata: EstimateDemandLineMetadata;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RateCard {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
effectiveFrom?: Date | null;
|
||||
effectiveTo?: Date | null;
|
||||
source?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RateCardLine {
|
||||
id: string;
|
||||
rateCardId: string;
|
||||
roleId?: string | null;
|
||||
chapter?: string | null;
|
||||
location?: string | null;
|
||||
seniority?: string | null;
|
||||
workType?: string | null;
|
||||
serviceGroup?: string | null;
|
||||
costRateCents: number;
|
||||
billRateCents?: number | null;
|
||||
machineRateCents?: number | null;
|
||||
attributes: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ResourceCostSnapshot {
|
||||
id: string;
|
||||
estimateVersionId: string;
|
||||
resourceId?: string | null;
|
||||
sourceEid?: string | null;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
roleId?: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
fte?: number | null;
|
||||
location?: string | null;
|
||||
country?: string | null;
|
||||
level?: string | null;
|
||||
workType?: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateMetric {
|
||||
id: string;
|
||||
estimateVersionId: string;
|
||||
key: string;
|
||||
label: string;
|
||||
metricGroup?: string | null;
|
||||
valueDecimal: number;
|
||||
valueCents?: number | null;
|
||||
currency?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateExportSummary {
|
||||
estimateId: string;
|
||||
estimateName: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
versionStatus: EstimateVersionStatus;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
baseCurrency: string;
|
||||
assumptionCount: number;
|
||||
scopeItemCount: number;
|
||||
demandLineCount: number;
|
||||
resourceSnapshotCount: number;
|
||||
totalHours: number;
|
||||
totalCostCents: number;
|
||||
totalPriceCents: number;
|
||||
marginCents: number;
|
||||
marginPercent: number;
|
||||
}
|
||||
|
||||
export interface EstimateExportArtifactPayload {
|
||||
schemaVersion: number;
|
||||
format: EstimateExportFormat;
|
||||
mimeType: string;
|
||||
encoding: "utf8" | "base64";
|
||||
fileExtension: string;
|
||||
generatedAt: string;
|
||||
byteLength: number;
|
||||
rowCount?: number | null;
|
||||
lineCount?: number | null;
|
||||
sheetNames?: string[];
|
||||
previewText?: string | null;
|
||||
content: string;
|
||||
summary: EstimateExportSummary;
|
||||
}
|
||||
|
||||
export interface EstimateExport {
|
||||
id: string;
|
||||
estimateVersionId: string;
|
||||
format: EstimateExportFormat;
|
||||
fileName: string;
|
||||
storageKey?: string | null;
|
||||
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateDemandSummary {
|
||||
totalHours: number;
|
||||
totalCostCents: number;
|
||||
totalPriceCents: number;
|
||||
marginCents: number;
|
||||
marginPercent: number;
|
||||
}
|
||||
|
||||
export interface EstimatePlanningHandoffAllocationRef {
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId?: string | null;
|
||||
isPlaceholder: boolean;
|
||||
}
|
||||
|
||||
// --- Experience Multipliers ---
|
||||
|
||||
export interface ExperienceMultiplierRule {
|
||||
id: string;
|
||||
multiplierSetId: string;
|
||||
chapter?: string | null;
|
||||
location?: string | null;
|
||||
level?: string | null;
|
||||
costMultiplier: number;
|
||||
billMultiplier: number;
|
||||
shoringRatio?: number | null;
|
||||
additionalEffortRatio?: number | null;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ExperienceMultiplierSet {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isDefault: boolean;
|
||||
rules: ExperienceMultiplierRule[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RateAdjustmentInput {
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
hours: number;
|
||||
chapter?: string | null;
|
||||
location?: string | null;
|
||||
level?: string | null;
|
||||
}
|
||||
|
||||
export interface RateAdjustmentResult {
|
||||
adjustedCostRateCents: number;
|
||||
adjustedBillRateCents: number;
|
||||
adjustedHours: number;
|
||||
appliedRules: string[];
|
||||
}
|
||||
|
||||
// --- Effort Rules ---
|
||||
|
||||
export type EffortUnitMode = "per_frame" | "per_item" | "flat";
|
||||
|
||||
export interface EffortRule {
|
||||
id: string;
|
||||
ruleSetId: string;
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter?: string | null;
|
||||
unitMode: EffortUnitMode;
|
||||
hoursPerUnit: number;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface EffortRuleSet {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isDefault: boolean;
|
||||
rules: EffortRule[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EffortRuleExpandedLine {
|
||||
scopeItemName: string;
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter?: string | null;
|
||||
hours: number;
|
||||
unitMode: EffortUnitMode;
|
||||
unitCount: number;
|
||||
hoursPerUnit: number;
|
||||
}
|
||||
|
||||
export interface EffortRuleExpansionResult {
|
||||
lines: EffortRuleExpandedLine[];
|
||||
warnings: string[];
|
||||
unmatchedScopeItems: string[];
|
||||
}
|
||||
|
||||
export type PhasingPattern = "even" | "front_loaded" | "back_loaded" | "custom";
|
||||
|
||||
export interface WeeklyPhasingConfig {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
pattern: PhasingPattern;
|
||||
}
|
||||
|
||||
export interface EstimatePlanningHandoffResult {
|
||||
estimateId: string;
|
||||
estimateVersionId: string;
|
||||
estimateVersionNumber: number;
|
||||
projectId: string;
|
||||
createdCount: number;
|
||||
placeholderCount: number;
|
||||
assignedCount: number;
|
||||
fallbackPlaceholderCount: number;
|
||||
allocations: EstimatePlanningHandoffAllocationRef[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from "./enums.js";
|
||||
export * from "./dynamic-fields.js";
|
||||
export * from "./resource.js";
|
||||
export * from "./project.js";
|
||||
export * from "./allocation.js";
|
||||
export * from "./engine.js";
|
||||
export * from "./staffing.js";
|
||||
export * from "./vacation.js";
|
||||
export * from "./role.js";
|
||||
export type { Notification } from "./notification.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./columns.js";
|
||||
export * from "./dashboard.js";
|
||||
export * from "./estimate.js";
|
||||
export * from "./country.js";
|
||||
export * from "./org-unit.js";
|
||||
export * from "./utilization-category.js";
|
||||
export * from "./client.js";
|
||||
export * from "./management-level.js";
|
||||
export * from "./dispo-import.js";
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface ManagementLevelGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
targetPercentage: number;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ManagementLevel {
|
||||
id: string;
|
||||
name: string;
|
||||
groupId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ManagementLevelGroupWithLevels extends ManagementLevelGroup {
|
||||
levels: ManagementLevel[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
entityId?: string | null;
|
||||
entityType?: string | null;
|
||||
readAt?: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface OrgUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName?: string | null;
|
||||
level: number;
|
||||
parentId?: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface OrgUnitTree extends OrgUnit {
|
||||
children: OrgUnitTree[];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { SystemRole } from "./enums.js";
|
||||
|
||||
export const PermissionKey = {
|
||||
VIEW_COSTS: "viewCosts",
|
||||
EXPORT_DATA: "exportData",
|
||||
IMPORT_DATA: "importData",
|
||||
APPROVE_VACATIONS: "approveVacations",
|
||||
MANAGE_BLUEPRINTS: "manageBlueprints",
|
||||
VIEW_ALL_RESOURCES: "viewAllResources",
|
||||
MANAGE_RESOURCES: "manageResources",
|
||||
MANAGE_PROJECTS: "manageProjects",
|
||||
MANAGE_ALLOCATIONS: "manageAllocations",
|
||||
MANAGE_ROLES: "manageRoles",
|
||||
MANAGE_USERS: "manageUsers",
|
||||
VIEW_SCORES: "viewScores",
|
||||
} as const;
|
||||
|
||||
export type PermissionKey = (typeof PermissionKey)[keyof typeof PermissionKey];
|
||||
|
||||
export interface PermissionOverrides {
|
||||
granted?: PermissionKey[];
|
||||
denied?: PermissionKey[];
|
||||
chapterIds?: string[]; // wenn gesetzt: Manager sieht nur diese Chapters
|
||||
}
|
||||
|
||||
// Default-Permissions pro SystemRole
|
||||
export const ROLE_DEFAULT_PERMISSIONS: Record<SystemRole, PermissionKey[]> = {
|
||||
ADMIN: Object.values(PermissionKey),
|
||||
MANAGER: [
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.EXPORT_DATA,
|
||||
PermissionKey.IMPORT_DATA,
|
||||
PermissionKey.APPROVE_VACATIONS,
|
||||
PermissionKey.VIEW_ALL_RESOURCES,
|
||||
PermissionKey.MANAGE_RESOURCES,
|
||||
PermissionKey.MANAGE_PROJECTS,
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.MANAGE_ROLES,
|
||||
PermissionKey.VIEW_SCORES,
|
||||
],
|
||||
CONTROLLER: [
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.EXPORT_DATA,
|
||||
PermissionKey.VIEW_ALL_RESOURCES,
|
||||
],
|
||||
USER: [],
|
||||
VIEWER: [],
|
||||
};
|
||||
|
||||
export function resolvePermissions(
|
||||
systemRole: SystemRole,
|
||||
overrides?: PermissionOverrides | null
|
||||
): Set<PermissionKey> {
|
||||
const base = new Set<PermissionKey>(ROLE_DEFAULT_PERMISSIONS[systemRole] ?? []);
|
||||
if (overrides?.granted) {
|
||||
for (const p of overrides.granted) base.add(p);
|
||||
}
|
||||
if (overrides?.denied) {
|
||||
for (const p of overrides.denied) base.delete(p);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
permissions: Set<PermissionKey>,
|
||||
key: PermissionKey
|
||||
): boolean {
|
||||
return permissions.has(key);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type AllocationType, type OrderType, type ProjectStatus } from "./enums.js";
|
||||
|
||||
export interface StaffingRequirement {
|
||||
id: string;
|
||||
role: string;
|
||||
roleId?: string;
|
||||
requiredSkills: string[];
|
||||
preferredSkills?: string[];
|
||||
hoursPerDay: number;
|
||||
headcount: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
notes?: string;
|
||||
chapter?: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
orderType: OrderType;
|
||||
allocationType: AllocationType;
|
||||
winProbability: number;
|
||||
budgetCents: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
staffingReqs: StaffingRequirement[];
|
||||
dynamicFields: Record<string, unknown>;
|
||||
blueprintId?: string | null;
|
||||
status: ProjectStatus;
|
||||
responsiblePerson?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface ValueScoreBreakdown {
|
||||
skillDepth: number; // 0-100: avg proficiency normalized
|
||||
skillBreadth: number; // 0-100: skill count capped at 10
|
||||
costEfficiency: number; // 0-100: inverse LCR vs org max
|
||||
chargeability: number; // 0-100: actual vs target gap
|
||||
experience: number; // 0-100: avg yearsExperience capped at 10yr
|
||||
total: number; // 0-100: weighted composite
|
||||
}
|
||||
|
||||
export interface WeekdayAvailability {
|
||||
monday: number;
|
||||
tuesday: number;
|
||||
wednesday: number;
|
||||
thursday: number;
|
||||
friday: number;
|
||||
saturday?: number;
|
||||
sunday?: number;
|
||||
}
|
||||
|
||||
export interface SkillEntry {
|
||||
skill: string;
|
||||
category?: string;
|
||||
proficiency: 1 | 2 | 3 | 4 | 5;
|
||||
yearsExperience?: number;
|
||||
certified?: boolean;
|
||||
isMainSkill?: boolean;
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
chapter?: string | null;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
currency: string;
|
||||
chargeabilityTarget: number;
|
||||
availability: WeekdayAvailability;
|
||||
skills: SkillEntry[];
|
||||
dynamicFields: Record<string, unknown>;
|
||||
blueprintId?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
roles?: Array<{ roleId: string; isPrimary: boolean; role: { id: string; name: string; color: string | null } }>;
|
||||
portfolioUrl?: string | null;
|
||||
roleId?: string | null;
|
||||
aiSummary?: string | null;
|
||||
aiSummaryUpdatedAt?: Date | null;
|
||||
skillMatrixUpdatedAt?: Date | null;
|
||||
valueScore?: number | null;
|
||||
valueScoreBreakdown?: ValueScoreBreakdown | null;
|
||||
valueScoreUpdatedAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ResourceRole {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
roleId: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface RoleWithResourceCount extends Role {
|
||||
_count: {
|
||||
resourceRoles: number;
|
||||
allocations: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface StaffingSuggestion {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
eid: string;
|
||||
score: number;
|
||||
scoreBreakdown: ScoreBreakdown;
|
||||
matchedSkills: string[];
|
||||
missingSkills: string[];
|
||||
availabilityConflicts: string[];
|
||||
estimatedDailyCostCents: number;
|
||||
currentUtilization: number;
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
skillScore: number;
|
||||
availabilityScore: number;
|
||||
costScore: number;
|
||||
utilizationScore: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UtilizationAnalysis {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
chargeabilityTarget: number;
|
||||
currentChargeability: number;
|
||||
chargeabilityGap: number;
|
||||
allocations: UtilizationPeriod[];
|
||||
overallocatedDays: string[];
|
||||
underutilizedDays: string[];
|
||||
}
|
||||
|
||||
export interface UtilizationPeriod {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
projectName: string;
|
||||
isChargeable: boolean;
|
||||
}
|
||||
|
||||
export interface CapacityWindow {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
availableHoursPerDay: number;
|
||||
availableDays: number;
|
||||
totalAvailableHours: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface UtilizationCategory {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { type VacationType, type VacationStatus } from "./enums.js";
|
||||
|
||||
export interface Vacation {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
type: VacationType;
|
||||
status: VacationStatus;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
note?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
isHalfDay: boolean;
|
||||
halfDayPart?: string | null; // "MORNING" | "AFTERNOON"
|
||||
approvedById?: string | null;
|
||||
approvedAt?: Date | null;
|
||||
requestedById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface VacationEntitlement {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
year: number;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/** Computed balance for a resource in a year — not stored, returned by API */
|
||||
export interface VacationBalance {
|
||||
year: number;
|
||||
resourceId: string;
|
||||
entitledDays: number; // base quota + carryover
|
||||
carryoverDays: number;
|
||||
usedDays: number; // APPROVED ANNUAL+OTHER
|
||||
pendingDays: number; // PENDING ANNUAL+OTHER
|
||||
remainingDays: number; // entitledDays - usedDays - pendingDays
|
||||
sickDays: number; // APPROVED SICK (informational only, not subtracted)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@planarchy/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user