chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
+24
View File
@@ -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);
});
});
+33
View File
@@ -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];
}
+61
View File
@@ -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);
}
+3
View File
@@ -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>;
+15
View File
@@ -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>;
+179
View File
@@ -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>[];
}
+18
View File
@@ -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[];
}
+29
View File
@@ -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>>;
+35
View File
@@ -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;
}
+166
View File
@@ -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[];
+158
View File
@@ -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;
}
+88
View File
@@ -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;
}
+151
View File
@@ -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",
}
+339
View File
@@ -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[];
}
+20
View File
@@ -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[];
}
+11
View File
@@ -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;
}
+15
View File
@@ -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[];
}
+69
View File
@@ -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);
}
+34
View File
@@ -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;
}
+55
View File
@@ -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;
}
+23
View File
@@ -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;
};
}
+49
View File
@@ -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;
}
+43
View File
@@ -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)
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@planarchy/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
});