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
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@planarchy/db",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts"
},
"scripts": {
"db:push": "prisma db push --schema ./prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema ./prisma/schema.prisma",
"db:migrate:deploy": "prisma migrate deploy --schema ./prisma/schema.prisma",
"db:seed": "tsx src/seed.ts",
"db:seed:dispo-v2": "tsx src/seed-dispo-v2.ts",
"db:seed:vacations": "dotenv -e ../../.env -- tsx src/seed-vacations.ts",
"db:reset:dispo": "tsx src/reset-dispo-import.ts",
"db:excel": "tsx src/generate-excel.ts",
"db:studio": "prisma studio --schema ./prisma/schema.prisma",
"db:generate": "prisma generate --schema ./prisma/schema.prisma",
"test:unit": "tsx --test src/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"@planarchy/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"exceljs": "^4.4.0",
"prisma": "^5.22.0"
},
"devDependencies": {
"@planarchy/tsconfig": "workspace:*",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env["NODE_ENV"] === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env["NODE_ENV"] !== "production") {
globalForPrisma.prisma = prisma;
}
export default prisma;
+334
View File
@@ -0,0 +1,334 @@
/**
* Generate samples/PlanarchyExamples.xlsx from the live database.
*
* Run from repo root:
* DATABASE_URL=postgresql://planarchy:planarchy_dev@localhost:5433/planarchy \
* pnpm --filter @planarchy/db tsx src/generate-excel.ts
*/
import { PrismaClient } from "@prisma/client";
import ExcelJS from "exceljs";
import path from "path";
import { fileURLToPath } from "url";
const prisma = new PrismaClient();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ─── Colour palette ──────────────────────────────────────────────────────────
const COLORS = {
headerBg: "FF1E3A5F", // dark navy
headerFg: "FFFFFFFF",
altRow: "FFF5F7FA",
border: "FFCDD5E0",
// status
completed: "FFD1FAE5",
active: "FFDBEAFE",
onHold: "FFFEF3C7",
draft: "FFF3F4F6",
cancelled: "FFFEE2E2",
// allocation status
allocActive: "FFDBEAFE",
allocConfirmed: "FFD1FAE5",
allocProposed: "FFFEF3C7",
allocCompleted: "FFF3F4F6",
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtDate(d: Date | null): string {
if (!d) return "";
return d.toISOString().split("T")[0]!;
}
function fmtCents(cents: number): number {
return Math.round(cents) / 100;
}
type AnyRow = ExcelJS.Row;
function applyHeader(ws: ExcelJS.Worksheet, columns: ExcelJS.Column[]) {
ws.columns = columns;
const headerRow: AnyRow = ws.getRow(1);
headerRow.eachCell((cell) => {
cell.font = { bold: true, color: { argb: COLORS.headerFg }, size: 10 };
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: COLORS.headerBg } };
cell.border = {
bottom: { style: "thin", color: { argb: COLORS.border } },
};
cell.alignment = { vertical: "middle", horizontal: "center", wrapText: false };
});
headerRow.height = 22;
}
function styleDataRows(ws: ExcelJS.Worksheet, startRow: number, totalRows: number) {
for (let r = startRow; r <= startRow + totalRows - 1; r++) {
const row: AnyRow = ws.getRow(r);
const isAlt = (r - startRow) % 2 === 1;
row.eachCell({ includeEmpty: true }, (cell) => {
if (isAlt) {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: COLORS.altRow } };
}
cell.font = { size: 10 };
cell.alignment = { vertical: "middle" };
cell.border = {
bottom: { style: "hair", color: { argb: COLORS.border } },
};
});
row.height = 18;
}
}
function highlightCell(cell: ExcelJS.Cell, argb: string) {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: argb } };
}
function statusColor(status: string): string {
switch (status) {
case "COMPLETED": return COLORS.completed;
case "ACTIVE": return COLORS.active;
case "ON_HOLD": return COLORS.onHold;
case "DRAFT": return COLORS.draft;
case "CANCELLED": return COLORS.cancelled;
default: return COLORS.draft;
}
}
function allocStatusColor(status: string): string {
switch (status) {
case "ACTIVE": return COLORS.allocActive;
case "CONFIRMED": return COLORS.allocConfirmed;
case "PROPOSED": return COLORS.allocProposed;
case "COMPLETED": return COLORS.allocCompleted;
default: return COLORS.draft;
}
}
// ─── Sheet builders ───────────────────────────────────────────────────────────
async function buildResourcesSheet(wb: ExcelJS.Workbook) {
const resources = await prisma.resource.findMany({ orderBy: { chapter: "asc" } });
const ws = wb.addWorksheet("Resources", {
views: [{ state: "frozen", ySplit: 1 }],
});
applyHeader(ws, [
{ header: "EID", key: "eid", width: 22 },
{ header: "Display Name", key: "displayName", width: 24 },
{ header: "Chapter", key: "chapter", width: 30 },
{ header: "Type of Work", key: "typeOfWork", width: 30 },
{ header: "City", key: "city", width: 16 },
{ header: "Employee Type", key: "empType", width: 16 },
{ header: "LCR (€/h)", key: "lcr", width: 12 },
{ header: "UCR (€/h)", key: "ucr", width: 12 },
{ header: "Chargeability %", key: "chargeability",width: 16 },
{ header: "Mon h", key: "mon", width: 8 },
{ header: "Tue h", key: "tue", width: 8 },
{ header: "Wed h", key: "wed", width: 8 },
{ header: "Thu h", key: "thu", width: 8 },
{ header: "Fri h", key: "fri", width: 8 },
{ header: "Skills", key: "skills", width: 40 },
] as ExcelJS.Column[]);
let rowIdx = 2;
for (const res of resources) {
const avail = res.availability as {
monday: number; tuesday: number; wednesday: number; thursday: number; friday: number;
};
const dynFields = res.dynamicFields as { workType?: string; city?: string; employeeType?: string };
const skills = (res.skills as Array<{ skill: string; proficiency: number }> | null) ?? [];
const skillStr = skills.map((s) => `${s.skill} (${s.proficiency})`).join(", ");
const row: AnyRow = ws.getRow(rowIdx++);
row.values = [
res.eid,
res.displayName,
res.chapter,
dynFields.workType ?? "",
dynFields.city ?? "",
dynFields.employeeType ?? "",
fmtCents(res.lcrCents),
fmtCents(res.ucrCents),
Math.round(res.chargeabilityTarget),
avail.monday,
avail.tuesday,
avail.wednesday,
avail.thursday,
avail.friday,
skillStr,
];
row.commit();
}
styleDataRows(ws, 2, resources.length);
ws.autoFilter = { from: "A1", to: "O1" };
}
async function buildProjectsSheet(wb: ExcelJS.Workbook) {
const projects = await prisma.project.findMany({ orderBy: { startDate: "asc" } });
const ws = wb.addWorksheet("Projects", {
views: [{ state: "frozen", ySplit: 1 }],
});
applyHeader(ws, [
{ header: "Short Code", key: "code", width: 14 },
{ header: "Name", key: "name", width: 46 },
{ header: "Client", key: "client", width: 14 },
{ header: "Order Type", key: "orderType", width: 14 },
{ header: "Alloc Type", key: "allocType", width: 12 },
{ header: "Status", key: "status", width: 14 },
{ header: "Start Date", key: "startDate", width: 14 },
{ header: "End Date", key: "endDate", width: 14 },
{ header: "Win Prob %", key: "winProb", width: 12 },
{ header: "Budget (€)", key: "budget", width: 16 },
{ header: "Responsible", key: "responsible", width: 24 },
] as ExcelJS.Column[]);
let rowIdx = 2;
for (const proj of projects) {
const dynFields = proj.dynamicFields as { clientUnit?: string };
const row: AnyRow = ws.getRow(rowIdx);
row.values = [
proj.shortCode,
proj.name,
dynFields.clientUnit ?? "",
proj.orderType,
proj.allocationType,
proj.status,
fmtDate(proj.startDate),
fmtDate(proj.endDate),
proj.winProbability,
fmtCents(proj.budgetCents),
proj.responsiblePerson ?? "",
];
row.commit();
// Colour the Status cell
highlightCell(row.getCell("status"), statusColor(proj.status));
rowIdx++;
}
styleDataRows(ws, 2, projects.length);
ws.autoFilter = { from: "A1", to: "K1" };
}
async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
const allocations = await prisma.allocation.findMany({
orderBy: [{ project: { startDate: "asc" } }, { resource: { displayName: "asc" } }],
include: {
resource: { select: { eid: true, displayName: true, chapter: true } },
project: { select: { shortCode: true, name: true } },
},
});
const ws = wb.addWorksheet("Allocations", {
views: [{ state: "frozen", ySplit: 1 }],
});
applyHeader(ws, [
{ header: "Project Code", key: "projCode", width: 14 },
{ header: "Project Name", key: "projName", width: 40 },
{ header: "EID", key: "eid", width: 20 },
{ header: "Resource Name", key: "resource", width: 24 },
{ header: "Chapter", key: "chapter", width: 28 },
{ header: "Role", key: "role", width: 20 },
{ header: "Start Date", key: "startDate", width: 14 },
{ header: "End Date", key: "endDate", width: 14 },
{ header: "h/Day", key: "hours", width: 8 },
{ header: "Daily Cost (€)", key: "dailyCost", width: 16 },
{ header: "Status", key: "status", width: 14 },
] as ExcelJS.Column[]);
let rowIdx = 2;
for (const alloc of allocations) {
const row: AnyRow = ws.getRow(rowIdx);
row.values = [
alloc.project.shortCode,
alloc.project.name,
alloc.resource?.eid ?? "",
alloc.resource?.displayName ?? "",
alloc.resource?.chapter ?? "",
alloc.role ?? "",
fmtDate(alloc.startDate),
fmtDate(alloc.endDate),
alloc.hoursPerDay,
fmtCents(alloc.dailyCostCents),
alloc.status,
];
row.commit();
highlightCell(row.getCell("status"), allocStatusColor(alloc.status));
rowIdx++;
}
styleDataRows(ws, 2, allocations.length);
ws.autoFilter = { from: "A1", to: "K1" };
}
async function buildSummarySheet(wb: ExcelJS.Workbook) {
const [resourceCount, projectCount, allocationCount] = await Promise.all([
prisma.resource.count(),
prisma.project.count(),
prisma.allocation.count(),
]);
const ws = wb.addWorksheet("Summary");
ws.columns = [
{ key: "label", width: 30 },
{ key: "value", width: 20 },
] as ExcelJS.Column[];
const title = ws.getCell("A1");
title.value = "Planarchy — Seed Data Summary";
title.font = { bold: true, size: 14, color: { argb: COLORS.headerBg } };
ws.mergeCells("A1:B1");
ws.getRow(1).height = 28;
const generated = ws.getCell("A2");
generated.value = `Generated: ${new Date().toISOString().split("T")[0]}`;
generated.font = { italic: true, size: 10, color: { argb: "FF666666" } };
ws.mergeCells("A2:B2");
ws.getRow(2).height = 18;
const rows: [string, number | string][] = [
["Resources", resourceCount],
["Projects", projectCount],
["Allocations", allocationCount],
];
rows.forEach(([label, value], i) => {
const row = ws.getRow(4 + i);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true, size: 10 };
row.getCell(2).value = value;
row.getCell(2).font = { size: 10 };
row.height = 20;
});
}
// ─── Entry point ─────────────────────────────────────────────────────────────
async function main() {
console.log("Connecting to database...");
const wb = new ExcelJS.Workbook();
wb.creator = "Planarchy";
wb.created = new Date();
wb.modified = new Date();
console.log("Building sheets...");
await buildSummarySheet(wb);
await buildResourcesSheet(wb);
await buildProjectsSheet(wb);
await buildAllocationsSheet(wb);
const outPath = path.resolve(__dirname, "../../../samples/PlanarchyExamples.xlsx");
await wb.xlsx.writeFile(outPath);
console.log(`Excel written to: ${outPath}`);
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+2
View File
@@ -0,0 +1,2 @@
export { prisma, default } from "./client.js";
export * from "@prisma/client";
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
function resolveWorkspaceRoot() {
const currentDir = dirname(fileURLToPath(import.meta.url));
return resolve(currentDir, "../../../");
}
export function loadWorkspaceEnv() {
const envPath = resolve(resolveWorkspaceRoot(), ".env");
try {
const contents = readFileSync(envPath, "utf8");
for (const rawLine of contents.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separatorIndex = line.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 1).trim();
const quoted =
(rawValue.startsWith("\"") && rawValue.endsWith("\"")) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"));
const value = quoted ? rawValue.slice(1, -1) : rawValue;
if (process.env[key] == null) {
process.env[key] = value;
}
}
} catch {
// Leave environment untouched when .env is not present in the workspace.
}
}
export function resolveWorkspacePath(...segments: string[]) {
return resolve(resolveWorkspaceRoot(), ...segments);
}
+191
View File
@@ -0,0 +1,191 @@
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { hash } from "@node-rs/argon2";
import { SystemRole } from "@planarchy/shared";
import { PrismaClient } from "@prisma/client";
import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js";
loadWorkspaceEnv();
const prisma = new PrismaClient();
const DEFAULT_BACKUP_DIR = resolveWorkspacePath("packages", "db", "backups");
interface ResetOptions {
force: boolean;
skipBackup: boolean;
backupDir: string;
adminEmail: string;
adminPassword: string;
adminName: string;
}
function parseArgs(argv: string[]): ResetOptions {
const options: ResetOptions = {
force: false,
skipBackup: false,
backupDir: DEFAULT_BACKUP_DIR,
adminEmail: "admin@planarchy.dev",
adminPassword: "admin123",
adminName: "Planarchy Admin",
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--force") {
options.force = true;
continue;
}
if (argument === "--skip-backup") {
options.skipBackup = true;
continue;
}
if (argument === "--backup-dir") {
options.backupDir = resolve(argv[index + 1] ?? DEFAULT_BACKUP_DIR);
index += 1;
continue;
}
if (argument === "--admin-email") {
options.adminEmail = argv[index + 1] ?? options.adminEmail;
index += 1;
continue;
}
if (argument === "--admin-password") {
options.adminPassword = argv[index + 1] ?? options.adminPassword;
index += 1;
continue;
}
if (argument === "--admin-name") {
options.adminName = argv[index + 1] ?? options.adminName;
index += 1;
}
}
return options;
}
function createTimestamp() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, "\"\"")}"`;
}
function createDatabaseBackup(databaseUrl: string, backupDir: string): string {
mkdirSync(backupDir, { recursive: true });
const backupPath = resolve(backupDir, `dispo-reset-${createTimestamp()}.dump`);
execFileSync(
"pg_dump",
["--format=custom", "--file", backupPath, databaseUrl],
{
stdio: "inherit",
env: process.env,
},
);
return backupPath;
}
async function listPublicTables() {
return prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations'
ORDER BY tablename ASC
`;
}
async function truncatePublicTables() {
const tables = await listPublicTables();
if (tables.length === 0) {
return [];
}
const quotedTables = tables.map((table) => quoteIdentifier(table.tablename)).join(", ");
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${quotedTables} RESTART IDENTITY CASCADE`);
return tables.map((table) => table.tablename);
}
async function bootstrapPlatform(adminEmail: string, adminPassword: string, adminName: string) {
const passwordHash = await hash(adminPassword);
const admin = await prisma.user.create({
data: {
email: adminEmail,
name: adminName,
passwordHash,
systemRole: SystemRole.ADMIN,
},
});
await prisma.systemSettings.upsert({
where: { id: "singleton" },
update: {
vacationDefaultDays: 28,
},
create: {
id: "singleton",
vacationDefaultDays: 28,
},
});
return admin;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const databaseUrl = process.env.DATABASE_URL;
if (!options.force) {
throw new Error("Refusing to reset the database without --force.");
}
if (!databaseUrl) {
throw new Error("DATABASE_URL is not configured.");
}
let backupPath: string | null = null;
if (options.skipBackup) {
console.warn("Skipping pg_dump backup because --skip-backup was provided.");
} else {
try {
backupPath = createDatabaseBackup(databaseUrl, options.backupDir);
console.log(`Backup created at ${backupPath}`);
} catch (error) {
throw new Error(
`Backup failed. Install pg_dump or rerun with --skip-backup if this is an intentional disposable environment.\n${String(error)}`,
);
}
}
const truncatedTables = await truncatePublicTables();
console.log(`Truncated ${truncatedTables.length} public tables.`);
const admin = await bootstrapPlatform(options.adminEmail, options.adminPassword, options.adminName);
console.log(`Bootstrap admin created: ${admin.email}`);
if (backupPath) {
console.log(`Database backup: ${backupPath}`);
}
console.log("Dispo import reset/bootstrap complete.");
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+245
View File
@@ -0,0 +1,245 @@
/**
* Seed script for Dispo v2 reference data:
* Countries, MetroCities, OrgUnits, UtilizationCategories, Clients, ManagementLevels.
*
* Run: npx tsx src/seed-dispo-v2.ts
*/
import {
DISPO_REQUIRED_ROLE_SEEDS,
DISPO_UTILIZATION_CATEGORIES,
} from "@planarchy/shared";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log("Seeding Dispo v2 reference data...");
// ─── Countries + Metro Cities ─────────────────────────────────────────────
const countries = [
{ code: "CR", name: "Costa Rica", dailyWorkingHours: 8, cities: ["Costa Rica"] },
{ code: "DE", name: "Germany", dailyWorkingHours: 8, cities: ["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"] },
{ code: "HU", name: "Hungary", dailyWorkingHours: 8, cities: ["Hungary"] },
{ code: "IN", name: "India", dailyWorkingHours: 9, cities: ["India"] },
{ code: "IT", name: "Italy", dailyWorkingHours: 8, cities: ["Italy"] },
{ code: "PT", name: "Portugal", dailyWorkingHours: 8, cities: ["Lisbon"] },
{
code: "ES",
name: "Spain",
dailyWorkingHours: 8,
cities: ["Spain"],
scheduleRules: {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
},
},
{ code: "GB", name: "United Kingdom", dailyWorkingHours: 8, cities: ["Birmingham"] },
] as const;
for (const c of countries) {
const country = await prisma.country.upsert({
where: { code: c.code },
update: { name: c.name, dailyWorkingHours: c.dailyWorkingHours },
create: {
code: c.code,
name: c.name,
dailyWorkingHours: c.dailyWorkingHours,
...("scheduleRules" in c ? { scheduleRules: c.scheduleRules } : {}),
},
});
for (const cityName of c.cities) {
await prisma.metroCity.upsert({
where: { countryId_name: { countryId: country.id, name: cityName } },
update: {},
create: { name: cityName, countryId: country.id },
});
}
}
console.log(" Countries + MetroCities: done");
// ─── Org Unit Hierarchy ───────────────────────────────────────────────────
const l5 = await prisma.orgUnit.upsert({
where: { parentId_name: { parentId: null as unknown as string, name: "Content Production" } },
update: {},
create: { name: "Content Production", level: 5, sortOrder: 1 },
}).catch(async () => {
// parentId_name unique doesn't work with null parentId in all Prisma versions
const existing = await prisma.orgUnit.findFirst({ where: { name: "Content Production", level: 5, parentId: null } });
if (existing) return existing;
return prisma.orgUnit.create({ data: { name: "Content Production", level: 5, sortOrder: 1 } });
});
const l6Data = [
{ name: "CGI Content", sortOrder: 1 },
{ name: "CGI Technology", sortOrder: 2 },
{ name: "Creative Content Production", sortOrder: 3 },
{ name: "VFX", sortOrder: 4 },
];
const l7Data: Record<string, { name: string; sortOrder: number }[]> = {
"CGI Content": [
{ name: "Art Direction", sortOrder: 1 },
{ name: "Capability Development", sortOrder: 2 },
{ name: "CGI Production", sortOrder: 3 },
{ name: "Product Data Management", sortOrder: 4 },
{ name: "Program/Delivery Mgmt", sortOrder: 5 },
],
"CGI Technology": [
{ name: "CGI Development", sortOrder: 1 },
{ name: "IT Development", sortOrder: 2 },
],
"Creative Content Production": [
{ name: "Creative Content Production", sortOrder: 1 },
],
"VFX": [
{ name: "2D & Art Direction", sortOrder: 1 },
{ name: "3D", sortOrder: 2 },
{ name: "Program/Delivery Mgmt & Other", sortOrder: 3 },
],
};
for (const l6Item of l6Data) {
let l6 = await prisma.orgUnit.findFirst({ where: { name: l6Item.name, level: 6, parentId: l5.id } });
if (!l6) {
l6 = await prisma.orgUnit.create({
data: { name: l6Item.name, level: 6, parentId: l5.id, sortOrder: l6Item.sortOrder },
});
}
for (const l7Item of l7Data[l6Item.name] ?? []) {
const existing = await prisma.orgUnit.findFirst({ where: { name: l7Item.name, level: 7, parentId: l6.id } });
if (!existing) {
await prisma.orgUnit.create({
data: { name: l7Item.name, level: 7, parentId: l6.id, sortOrder: l7Item.sortOrder },
});
}
}
}
console.log(" OrgUnits: done");
// ─── Utilization Categories ───────────────────────────────────────────────
for (const cat of DISPO_UTILIZATION_CATEGORIES) {
await prisma.utilizationCategory.upsert({
where: { code: cat.code },
update: { name: cat.name, description: cat.description, sortOrder: cat.sortOrder },
create: cat,
});
}
console.log(" UtilizationCategories: done");
// ─── Roles ────────────────────────────────────────────────────────────────
for (const role of DISPO_REQUIRED_ROLE_SEEDS) {
await prisma.role.upsert({
where: { name: role.name },
update: { description: role.description },
create: role,
});
}
console.log(" Roles: done");
// ─── Management Level Groups + Levels ─────────────────────────────────────
const mgmtGroups = [
{ name: "Accenture Leadership", targetPercentage: 0.365, sortOrder: 1, levels: [] as string[] },
{ name: "Senior Manager", targetPercentage: 0.546, sortOrder: 2, levels: ["5-Associate Director", "6-Senior Manager"] },
{ name: "Manager", targetPercentage: 0.747, sortOrder: 3, levels: ["7-Manager"] },
{ name: "Consultant", targetPercentage: 0.808, sortOrder: 4, levels: ["8-Associate Manager", "9-Team Lead/Consultant"] },
{ name: "Analyst", targetPercentage: 0.805, sortOrder: 5, levels: ["10-Senior Analyst", "11-Analyst"] },
{ name: "Associate", targetPercentage: 0.770, sortOrder: 6, levels: ["12-Associate", "13-New Associate"] },
];
for (const grp of mgmtGroups) {
const group = await prisma.managementLevelGroup.upsert({
where: { name: grp.name },
update: { targetPercentage: grp.targetPercentage, sortOrder: grp.sortOrder },
create: { name: grp.name, targetPercentage: grp.targetPercentage, sortOrder: grp.sortOrder },
});
for (const levelName of grp.levels) {
await prisma.managementLevel.upsert({
where: { name: levelName },
update: { groupId: group.id },
create: { name: levelName, groupId: group.id },
});
}
}
console.log(" ManagementLevels: done");
// ─── Clients (WBS Master + Sub-entities) ──────────────────────────────────
const clients: { name: string; code?: string; children: string[] }[] = [
{ name: "BMW", code: "BMW", children: ["BMW AG"] },
{ name: "VOLKSWAGEN", code: "VW", children: ["Audi Business Innovation GmbH", "Dr. Ing. h.c. F. Porsche AG", "MAN Truck & Bus SE", "Volkswagen AG"] },
{ name: "DAIMLER", code: "DAIMLER", children: ["antoni garage GmbH & Co. KG", "Mercedes-Benz AG"] },
{ name: "EXOR-STELLANTIS", code: "STELLANTIS", children: ["AUTOMOBILES PEUGEOT", "FCA Italy S.p.A.", "Ferrari S.p.A", "MASERATI SPA A SOCIO UNICO"] },
{ name: "TATA MOTORS GROUP", code: "JLR", children: ["Jaguar Land Rover"] },
{ name: "SCHWARZ GROUP", code: "LIDL", children: ["Lidl Stiftung & Co. KG"] },
{ name: "INA-HOLDING SCHAEFFLER GMBH & CO KG", children: ["Schaeffler Technologies"] },
{ name: "AIRBUS GROUP", children: ["Airbus SAS"] },
{ name: "ALDI EINKAUF GMBH & CO. OHG", children: ["ALDI Einkauf SE & co. oHG", "ALDI SUED Dienstleistungs-SE & Co.oH"] },
{ name: "ARLA", children: ["Arla Foods amba"] },
{ name: "BLANC & FISCHER FAMILIENHOLDING", children: ["BLANCO GmbH + Co KG"] },
{ name: "BMDS & ITZBUND ASG", children: ["Bundesministerium der Finanzen"] },
{ name: "BOSCH", children: ["BSH Hausgeraete GmbH"] },
{ name: "CECONOMY AG", children: ["Media-Saturn-Holding GmbH"] },
{ name: "COMMERZBANK AG", children: ["COMMERZBANK AG"] },
{ name: "COOPERATIVE BANKS", children: ["Bank fuer Sozialwirtschaft"] },
{ name: "HENKEL", children: ["Henkel AG & Co. KGaA"] },
{ name: "JP MORGAN CHASE & CO", children: ["J.P. Morgan SE"] },
{ name: "L'OREAL", children: ["L'oreal Singapore Pte Ltd."] },
{ name: "LUFTHANSA", children: ["Lufthansa Technik AG"] },
{ name: "LVM", children: ["LVM Landwirtschaftlicher"] },
{ name: "NESTLE", children: ["Nestec Ltd.", "Societe des Produits Nestle S.A."] },
{ name: "NETFLIX, INC.", children: ["Bride Lake Productions, LLC", "FOUR FACTOR PRODUCTIONS"] },
{ name: "NOVARTIS", children: ["Novartis Pharma AG"] },
{ name: "NOVO NORDISK", children: ["Novo Nordisk Pharma GmbH"] },
{ name: "OTTO", children: ["bonprix Handelsgesellschaft mbH"] },
{ name: "PIF & SOVEREIGN FUNDS", children: ["Public Investment Fund"] },
{ name: "PROSIEBENSAT.1 MEDIA AG", children: ["Seven.One Entertainment Group GmbH"] },
{ name: "QIDDIYA INVESTMENT COMPANY", children: ["Qiddiyah Investment Company"] },
{ name: "RED BULL", children: ["RasenBallsport Leipzig GmbH"] },
{ name: "SIEMENS", children: ["Siemens AG"] },
{ name: "THUEGA", children: ["TAP Steuerungsgesellschaft"] },
{ name: "UNICREDIT GROUP", children: ["UniCredit Bank GmbH"] },
{ name: "WARNER BROS DISCOVERY", children: ["RANDOM PRODUCTIONS LLC"] },
{ name: "WESTLOTTO", children: ["Westdeutsche Lotterie GmbH & Co. OH"] },
];
for (let i = 0; i < clients.length; i++) {
const c = clients[i]!;
// Find or create master client
let master = await prisma.client.findFirst({ where: { name: c.name, parentId: null } });
if (!master) {
master = await prisma.client.create({
data: {
name: c.name,
...(c.code ? { code: c.code } : {}),
sortOrder: i + 1,
},
});
}
for (const childName of c.children) {
const existing = await prisma.client.findFirst({ where: { name: childName, parentId: master.id } });
if (!existing) {
await prisma.client.create({
data: { name: childName, parentId: master.id },
});
}
}
}
console.log(" Clients: done");
console.log("Dispo v2 seed complete.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+146
View File
@@ -0,0 +1,146 @@
/**
* seed-vacations.ts
* Populates 15 vacation days per resource per year (2025, 2026, 2027-partial).
* Spread across 3 blocks:
* Spring/Easter (5 days, fixed)
* Summer (5 days, staggered by resource index so teams don't all leave at once)
* Christmas/NYE (5 days, fixed)
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** ISO date string → Date at midnight UTC */
function d(iso: string): Date {
return new Date(iso + "T00:00:00.000Z");
}
/** Add calendar days to a Date */
function addDays(date: Date, n: number): Date {
const r = new Date(date);
r.setUTCDate(r.getUTCDate() + n);
return r;
}
// ─── Vacation blocks per year ─────────────────────────────────────────────────
// Each block is { start, end } of a MonFri week (5 working days).
// Summer has multiple offsets so resources are staggered (offset chosen by index % 4).
const BLOCKS: Record<
number,
{
spring: { start: string; end: string };
summer: { start: string; end: string }[];
christmas: { start: string; end: string };
}
> = {
2025: {
spring: { start: "2025-04-14", end: "2025-04-18" }, // MonFri Easter week
summer: [
{ start: "2025-07-07", end: "2025-07-11" },
{ start: "2025-07-21", end: "2025-07-25" },
{ start: "2025-08-04", end: "2025-08-08" },
{ start: "2025-08-18", end: "2025-08-22" },
],
christmas: { start: "2025-12-22", end: "2025-12-26" }, // MonFri
},
2026: {
spring: { start: "2026-03-30", end: "2026-04-03" }, // MonFri Easter week
summer: [
{ start: "2026-07-06", end: "2026-07-10" },
{ start: "2026-07-20", end: "2026-07-24" },
{ start: "2026-08-03", end: "2026-08-07" },
{ start: "2026-08-17", end: "2026-08-21" },
],
christmas: { start: "2026-12-21", end: "2026-12-25" }, // MonFri
},
2027: {
// Partial year — spring only (for future view context)
spring: { start: "2027-03-29", end: "2027-04-02" },
summer: [
{ start: "2027-07-05", end: "2027-07-09" },
{ start: "2027-07-19", end: "2027-07-23" },
{ start: "2027-08-02", end: "2027-08-06" },
{ start: "2027-08-16", end: "2027-08-20" },
],
christmas: { start: "2027-12-20", end: "2027-12-24" },
},
};
const YEARS = [2025, 2026, 2027];
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
// Get admin user to act as approver (fall back to manager, then any user)
const admin =
(await prisma.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } })) ??
(await prisma.user.findFirst({ where: { systemRole: "MANAGER" }, select: { id: true } })) ??
(await prisma.user.findFirst({ select: { id: true } }));
if (!admin) {
throw new Error("No users found — run the main seed first.");
}
// Get all resources ordered by eid for stable staggering
const resources = await prisma.resource.findMany({
where: { isActive: true },
select: { id: true, eid: true },
orderBy: { eid: "asc" },
});
console.log(`Found ${resources.length} active resources. Admin id: ${admin.id}`);
// Delete existing vacations to allow re-running
const deleted = await prisma.vacation.deleteMany({});
console.log(`Deleted ${deleted.count} existing vacation records.`);
let created = 0;
for (let i = 0; i < resources.length; i++) {
const resource = resources[i]!;
const summerOffset = i % 4; // 0-3 → pick one of the 4 staggered summer weeks
for (const year of YEARS) {
const blocks = BLOCKS[year]!;
const summerBlock = blocks.summer[summerOffset]!;
const vacationBlocks = [
{ type: "ANNUAL" as const, ...blocks.spring },
{ type: "ANNUAL" as const, ...summerBlock },
{ type: "ANNUAL" as const, ...blocks.christmas },
];
for (const block of vacationBlocks) {
await prisma.vacation.create({
data: {
resourceId: resource.id,
type: block.type,
status: "APPROVED",
startDate: d(block.start),
endDate: d(block.end),
requestedById: admin.id,
approvedById: admin.id,
approvedAt: new Date(),
note: `${year} annual leave`,
},
});
created++;
}
}
console.log(`${resource.eid}${YEARS.length * 3} vacation blocks`);
}
console.log(`\nDone! Created ${created} vacation records.`);
console.log(` ${resources.length} resources × ${YEARS.length} years × 3 blocks = ${resources.length * YEARS.length * 3} expected`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => void prisma.$disconnect());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+430
View File
@@ -0,0 +1,430 @@
/**
* Updates PlanarchyExamples.xlsx with missing columns and documentation.
* Adds: Display Name, Email, Skills, Planarchy Notes columns to EID sheet.
* Adds: Start Date, End Date, Status, Planarchy Notes columns to Projects sheet.
*/
import ExcelJS from "exceljs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const EXCEL_PATH = join(__dirname, "../../../samples/PlanarchyExamples.xlsx");
// ─── Helpers ─────────────────────────────────────────────────────────────────
function toDisplayName(eid) {
return eid
.split(".")
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join(" ");
}
function toEmail(eid) {
return `${eid}@planarchy.example`;
}
function computeSkillLabel(chapter, typeOfWork) {
if (chapter === "Project Management") return "Project Manager | Scrum Master";
if (chapter === "Digital Content Production") return "Unreal Engine | 3D Lighting | 3D Modeling";
if (chapter === "Product Data Management") return "Visualization Logic | Quality Assurance";
if (chapter === "Art Direction") return "Art Direction | 2D Compositing";
if (chapter === "CGI-Dev" && typeOfWork === "Dev Unreal") return "Unreal Dev | Technical Architect";
if (chapter === "CGI-Dev" && typeOfWork === "Dev Frontend") return "Frontend Dev | Backend Dev";
return typeOfWork;
}
function computePlanarchyEid(eid) {
// In Planarchy the EID stays as firstname.lastname (unique key)
return eid;
}
// Project date table (matches seed.ts)
const PROJECT_DATES = {
JLFJFL: ["2024-01-15", "2024-05-31", "COMPLETED"],
JZTFG: ["2024-02-01", "2024-09-30", "COMPLETED"],
UZGHD: ["2024-03-01", "2024-07-31", "COMPLETED"],
DSFGHF: ["2024-04-01", "2024-12-31", "COMPLETED"],
TZUZG: ["2024-05-15", "2025-01-31", "COMPLETED"],
KLJLOH: ["2024-06-01", "2024-10-31", "COMPLETED"],
FGHJJ: ["2024-07-01", "2025-01-31", "COMPLETED"],
FFGHG: ["2024-08-01", "2024-12-31", "COMPLETED"],
ARGFH: ["2024-09-01", "2025-03-31", "COMPLETED"],
ERTGR: ["2024-10-01", "2025-01-31", "COMPLETED"],
HFGRT: ["2024-11-01", "2025-06-30", "COMPLETED"],
WERTF: ["2024-12-01", "2025-04-30", "COMPLETED"],
RDFGH: ["2025-01-15", "2025-08-31", "ACTIVE"],
HJZGJ: ["2025-02-01", "2025-10-31", "ACTIVE"],
SRTZRT: ["2025-03-01", "2025-09-30", "DRAFT"],
STTHG: ["2025-04-01", "2026-01-31", "ACTIVE"],
EDDGG: ["2025-05-01", "2025-12-31", "ACTIVE"],
FGHHGF: ["2025-06-01", "2026-03-31", "ACTIVE"],
AFDGH: ["2025-07-01", "2025-12-31", "ACTIVE"],
HTZUG: ["2025-08-01", "2026-04-30", "ON_HOLD"],
MUIUF: ["2025-09-01", "2026-06-30", "ACTIVE"],
FGJUO: ["2025-10-01", "2026-02-28", "ACTIVE"],
ZUITJZ: ["2025-11-01", "2026-08-31", "ACTIVE"],
SACVFH: ["2025-12-01", "2026-06-30", "ACTIVE"],
EWFSFH: ["2026-01-15", "2026-09-30", "ACTIVE"],
NTZJJ: ["2026-02-01", "2026-10-31", "ACTIVE"],
EFGTZ: ["2026-03-01", "2026-12-31", "ACTIVE"],
SJUIL: ["2026-04-01", "2026-11-30", "DRAFT"],
SAERZ: ["2026-05-01", "2027-01-31", "DRAFT"],
HJUOP: ["2026-06-01", "2027-03-31", "ACTIVE"],
AERGH: ["2026-07-01", "2026-12-31", "DRAFT"],
POUILZ: ["2026-08-01", "2027-02-28", "DRAFT"],
EWERZ: ["2026-09-01", "2027-06-30", "DRAFT"],
HZIOP: ["2026-10-01", "2027-04-30", "DRAFT"],
DGHBN: ["2026-11-01", "2027-05-31", "DRAFT"],
KUOOL: ["2026-12-01", "2027-09-30", "ACTIVE"],
};
const HEADER_FILL = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FF4F46E5" }, // indigo-600
};
const HEADER_FONT = { bold: true, color: { argb: "FFFFFFFF" }, size: 10 };
const DOC_FILL = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFF0F9FF" }, // light blue
};
const DOC_FONT = { italic: true, color: { argb: "FF475569" }, size: 9 };
const NEW_FILL = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFEEF2FF" }, // indigo-50
};
function styleNewHeader(cell, label) {
cell.value = label;
cell.fill = HEADER_FILL;
cell.font = HEADER_FONT;
cell.alignment = { wrapText: true, vertical: "middle" };
cell.border = { bottom: { style: "thin", color: { argb: "FF6366F1" } } };
}
function styleDocCell(cell, value) {
cell.value = value;
cell.fill = DOC_FILL;
cell.font = DOC_FONT;
cell.alignment = { wrapText: true, vertical: "top" };
}
function styleDataCell(cell, value) {
cell.value = value;
cell.fill = NEW_FILL;
cell.font = { size: 10 };
cell.alignment = { wrapText: false, vertical: "middle" };
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(EXCEL_PATH);
// ─── EID_Informationen ───────────────────────────────────────────────────
const eidSheet = workbook.getWorksheet("EID_Informationen");
if (!eidSheet) throw new Error("Sheet EID_Informationen not found");
// Add a "Documentation" row 2 below headers explaining each column
// First, let's add new columns to the right: N, O, P, Q
const eidLastCol = 14; // N
// Column N: Display Name
// Column O: Email
// Column P: Skills (derived)
// Column Q: Description / Notes for Planarchy
const newEidCols = [
{
col: 14, // N
header: "Display Name\n(auto-generated)",
doc: "Full display name derived from EID (firstname.lastname → Firstname Lastname). Used as the person's name in Planarchy.",
},
{
col: 15, // O
header: "Email\n(generated)",
doc: "Generated email: firstname.lastname@planarchy.example. Required unique field in Planarchy. Replace with real email in production.",
},
{
col: 16, // P
header: "Skills\n(derived from Chapter)",
doc: "Skill tags assigned based on Chapter + Type of Work. Format: 'SkillA | SkillB'. Stored as JSON array in Planarchy with proficiency 1-5. Senior (LCR ≥ 118) → 5, Mid-Senior (LCR ≥ 95) → 4, Mid → 3.",
},
{
col: 17, // Q
header: "Planarchy Notes",
doc: "How data maps to Planarchy:\n• EID = unique key (col A)\n• Chapter = chapter field\n• LCR / UCR → multiply by 100 for integer cents (€85.00 → 8500)\n• Hours fraction × 8 = daily availability hours\n• Chargeability → multiply by 100 for % (0.75 → 75%)\n• Employee type, City, Client Unit → stored in dynamicFields JSONB",
},
];
// Add row 1 column headers for new columns
const eidHeaderRow = eidSheet.getRow(1);
for (const { col, header } of newEidCols) {
styleNewHeader(eidSheet.getCell(1, col), header);
}
// Add documentation row after header (insert at row 2, shifting data down)
eidSheet.spliceRows(2, 0, []); // insert empty row
const eidDocRow = eidSheet.getRow(2);
eidDocRow.height = 55;
// Add doc text to new columns
for (const { col, doc } of newEidCols) {
styleDocCell(eidSheet.getCell(2, col), doc);
}
// Style existing header cells for doc row
for (let c = 1; c <= 13; c++) {
const cell = eidSheet.getCell(2, c);
cell.fill = DOC_FILL;
cell.font = DOC_FONT;
cell.alignment = { wrapText: true, vertical: "top" };
}
eidSheet.getCell(2, 1).value = "← Column documentation (row 2)";
// Now fill data rows (row 3 onward = data rows 1-36)
for (let r = 3; r <= 38; r++) {
const row = eidSheet.getRow(r);
const eid = row.getCell(1).value?.toString().trim();
if (!eid) continue;
const chapter = row.getCell(2).value?.toString().trim() ?? "";
const typeOfWork = row.getCell(3).value?.toString().trim() ?? "";
const lcr = parseFloat(row.getCell(8).value?.toString() ?? "0");
styleDataCell(eidSheet.getCell(r, 14), toDisplayName(eid));
styleDataCell(eidSheet.getCell(r, 15), toEmail(eid));
styleDataCell(eidSheet.getCell(r, 16), computeSkillLabel(chapter, typeOfWork));
if (r === 3) {
styleDataCell(eidSheet.getCell(r, 17), "See header row notes →");
}
}
// Set column widths
eidSheet.getColumn(14).width = 22;
eidSheet.getColumn(15).width = 34;
eidSheet.getColumn(16).width = 36;
eidSheet.getColumn(17).width = 50;
// Also add doc notes to existing header columns A-M in row 1
const eidExistingDocs = [
"Unique identifier. Used as EID in Planarchy (no EMP-XXX prefix needed). e.g. steve.rogers",
"Team / department. Maps to 'chapter' field in Planarchy.",
"Specialization within chapter. Stored in dynamicFields.workType.",
"Assigned client account. Stored in dynamicFields.clientUnit.",
"Unit-specific field. Currently unused — can be stored in dynamicFields.",
"Office city location. Stored in dynamicFields.city.",
"Employment type: Employee or Freelancer. Stored in dynamicFields.employeeType.",
"Loaded Cost Rate (LCR) in EUR/h. Multiply × 100 for Planarchy cents. e.g. 133.77 → 13377",
"Unloaded/Utilization Cost Rate (UCR) in EUR/h. Multiply × 100 for cents.",
"FTE fraction (1.0 = 40h/week, 0.8 = 4 days, 0.5 = 20h/week). Combined with col K for availability JSON.",
"Available weekdays. 'all' = Mon-Fri. Specific days listed = only those days active at 8h.",
"Chargeability target as decimal. Multiply × 100 for Planarchy % (0.75 → 75%).",
"(unused)",
];
for (let c = 1; c <= 13; c++) {
const doc = eidExistingDocs[c - 1];
if (doc) {
eidSheet.getCell(2, c).value = doc;
}
}
// ─── Projektinfomartionen ────────────────────────────────────────────────
const projSheet = workbook.getWorksheet("Projektinfomartionen");
if (!projSheet) throw new Error("Sheet Projektinfomartionen not found");
const newProjCols = [
{
col: 17, // Q
header: "Start Date\n(synthesized)",
doc: "Synthesized project start date (YYYY-MM-DD). Source Excel had no dates — spread across 2024-2027 based on win probability and project order.",
},
{
col: 18, // R
header: "End Date\n(synthesized)",
doc: "Synthesized end date (YYYY-MM-DD). Duration based on resource costs planned and person hours. Ranges from 3-12 months.",
},
{
col: 19, // S
header: "Status\n(derived)",
doc: "Planarchy status derived from 'is ordered' + win probability + date:\n• COMPLETED: ordered + 100% + past dates\n• ACTIVE: ordered + 100% + current/future\n• ON_HOLD: ordered but paused\n• DRAFT: not ordered or low win probability",
},
{
col: 20, // T
header: "Planarchy Notes",
doc: "How data maps to Planarchy:\n• Col C (short code) → shortCode (unique key)\n• Col B → name\n• BD/CH/UN → OrderType: BD / CHARGEABLE / INTERNAL\n• Internal/External → allocationType: INT / EXT\n• Resource Costs (col I) × 100 = budgetCents in Planarchy\n• Col H (chargability %) → stored in dynamicFields.chargeabilityPercent\n• Col J (person hours) → stored in dynamicFields.personHoursSold\n• Col O (classification) → stored in dynamicFields.classification",
},
];
// Add header row columns
const projHeaderRow = projSheet.getRow(1);
for (const { col, header } of newProjCols) {
styleNewHeader(projSheet.getCell(1, col), header);
}
// Insert documentation row 2
projSheet.spliceRows(2, 0, []);
const projDocRow = projSheet.getRow(2);
projDocRow.height = 55;
for (const { col, doc } of newProjCols) {
styleDocCell(projSheet.getCell(2, col), doc);
}
projSheet.getCell(2, 1).value = "← Column documentation (row 2)";
for (let c = 1; c <= 16; c++) {
const cell = projSheet.getCell(2, c);
cell.fill = DOC_FILL;
cell.font = DOC_FONT;
cell.alignment = { wrapText: true, vertical: "top" };
}
const projExistingDocs = [
"Client Unit tag. e.g. [DAI]=Daimler, [PAG]=Porsche AG, [BMW], [JLR]=Jaguar Land Rover. Stored in dynamicFields.clientUnit.",
"Full project name → maps to Planarchy 'name' field.",
"Short internal project code (5-6 chars) → maps to Planarchy 'shortCode' (unique key). e.g. JLFJFL",
"'yes'/'no' — whether the project is formally ordered. Drives status: yes+100% → ACTIVE/COMPLETED.",
"Order type: BD=Business Development, CH=Chargeable, UN=Internal/Unordered. Maps to Planarchy OrderType.",
"Win probability 0-100. Used in Planarchy 'winProbability' field for pipeline forecasting.",
"Allocation type: Internal → INT, External → EXT. Maps to Planarchy 'allocationType' field.",
"Chargeability % as decimal. Stored in dynamicFields.chargeabilityPercent.",
"Planned resource cost in EUR. Multiply × 100 for Planarchy budgetCents. e.g. 78799 → 7879900 cents.",
"Person hours planned/sold. Stored in dynamicFields.personHoursSold for budget tracking.",
"Team staffing (empty in source). Would list assigned EIDs. Handled by Allocations in Planarchy.",
"Day project sold (empty in source). Could be stored as dynamicFields.dateSold.",
"Project start date (empty in source → synthesized in col Q). Maps to Planarchy 'startDate'.",
"Project end date (empty in source → synthesized in col R). Maps to Planarchy 'endDate'.",
"Confidentiality: Confidential / Not Confidential. Stored in dynamicFields.classification.",
"Responsible EID / Owner (empty in source). Would map to a PM allocation in Planarchy.",
];
for (let c = 1; c <= 16; c++) {
const doc = projExistingDocs[c - 1];
if (doc) projSheet.getCell(2, c).value = doc;
}
// Fill data rows
for (let r = 3; r <= 38; r++) {
const row = projSheet.getRow(r);
const shortCode = row.getCell(3).value?.toString().trim();
if (!shortCode) continue;
const dates = PROJECT_DATES[shortCode];
if (dates) {
styleDataCell(projSheet.getCell(r, 17), dates[0]);
styleDataCell(projSheet.getCell(r, 18), dates[1]);
styleDataCell(projSheet.getCell(r, 19), dates[2]);
}
if (r === 3) {
styleDataCell(projSheet.getCell(r, 20), "See header row notes →");
}
}
projSheet.getColumn(17).width = 18;
projSheet.getColumn(18).width = 18;
projSheet.getColumn(19).width = 16;
projSheet.getColumn(20).width = 50;
// ─── Add a "Planarchy Data Model" sheet ──────────────────────────────────
let modelSheet = workbook.getWorksheet("Planarchy Data Model");
if (!modelSheet) {
modelSheet = workbook.addWorksheet("Planarchy Data Model");
}
const modelData = [
["Planarchy Data Model — Field Reference", "", "", "", ""],
["", "", "", "", ""],
["RESOURCE FIELDS", "", "", "", ""],
["Field", "Type", "Required", "Example", "Description"],
["eid", "String (unique)", "Yes", "steve.rogers", "Employee identifier. Must be unique. Shown in UI as EID badge."],
["displayName", "String", "Yes", "Steve Rogers", "Full display name shown in all views."],
["email", "String (unique)", "Yes", "steve.rogers@company.com", "Unique email address. Used for login if resource is also a user."],
["chapter", "String", "No", "Project Management", "Team or department. Used for grouping in dashboard and filters."],
["lcrCents", "Integer (cents)", "Yes", "13377", "Loaded Cost Rate in euro-cents (€/h × 100). e.g. €133.77/h → 13377"],
["ucrCents", "Integer (cents)", "Yes", "7465", "Unloaded/Utilization Cost Rate in euro-cents. e.g. €74.65/h → 7465"],
["currency", "String", "Yes", "EUR", "Currency code for LCR/UCR. Default EUR."],
["chargeabilityTarget", "Float (0-100)", "Yes", "75", "Target chargeability percentage. 75 = 75% of available hours should be chargeable."],
["availability", "JSON", "Yes", "{monday:8, ...}", "Available hours per weekday. Partial days for part-time. e.g. 4h = half day."],
["skills", "JSON Array", "No", "[{skill:'Unreal Engine', proficiency:4}]", "Skill entries with proficiency 1(Junior)5(Expert). Optional category and yearsExperience."],
["isActive", "Boolean", "Yes", "true", "Active resources appear in planning. Deactivated resources are hidden but kept in history."],
["dynamicFields", "JSON", "No", "{city:'Stuttgart', ...}", "Additional fields defined by Blueprint (e.g. city, clientUnit, workType, employeeType)."],
["", "", "", "", ""],
["PROJECT FIELDS", "", "", "", ""],
["Field", "Type", "Required", "Example", "Description"],
["shortCode", "String (unique)", "Yes", "STTHG", "Short internal project code. Unique key. Shown as monospace badge in UI."],
["name", "String", "Yes", "Spider-Man: Homecoming (2017)", "Full project name."],
["orderType", "Enum", "Yes", "CHARGEABLE", "BD = Business Development, CHARGEABLE = billable client work, INTERNAL = internal, OVERHEAD = overhead."],
["allocationType", "Enum", "Yes", "EXT", "INT = Internal allocation (internal team), EXT = External (client-facing)."],
["winProbability", "Integer (0-100)", "Yes", "100", "Probability this project proceeds. Used in pipeline reports. 100 = confirmed."],
["budgetCents", "Integer (cents)", "Yes", "6789900", "Total project budget in euro-cents. e.g. €67,899 → 6789900. Used for utilization tracking."],
["startDate", "Date", "Yes", "2025-04-01", "Project start date (ISO format)."],
["endDate", "Date", "Yes", "2026-01-31", "Project end date (ISO format)."],
["status", "Enum", "Yes", "ACTIVE", "DRAFT / ACTIVE / ON_HOLD / COMPLETED / CANCELLED. Drives timeline filtering."],
["staffingReqs", "JSON Array", "No", "[{role:'3D Artist', fteCount:2}]", "Staffing requirements. Used in Demand View widget to show gaps vs actual allocations."],
["dynamicFields", "JSON", "No", "{clientUnit:'[DAI]', ...}", "Blueprint fields: clientUnit, personHoursSold, classification, etc."],
["", "", "", "", ""],
["ALLOCATION FIELDS", "", "", "", ""],
["Field", "Type", "Required", "Example", "Description"],
["resourceId", "String (FK)", "Yes", "(cuid)", "Reference to Resource.id"],
["projectId", "String (FK)", "Yes", "(cuid)", "Reference to Project.id"],
["startDate", "Date", "Yes", "2025-04-01", "Start of allocation (often same as project start)."],
["endDate", "Date", "Yes", "2026-01-31", "End of allocation (can be partial project duration)."],
["hoursPerDay", "Float", "Yes", "8", "Hours per working day. 8 = full day. Can exceed 8 (overtime, shown in amber)."],
["percentage", "Float (0-100)", "Yes", "100", "Allocation percentage (100 = full-time, 50 = half-time)."],
["role", "String", "Yes", "Project Manager", "Role on this project (free text)."],
["dailyCostCents", "Integer (cents)", "Yes", "106960", "LCR × hoursPerDay × 100. Computed automatically on create."],
["status", "Enum", "Yes", "ACTIVE", "PROPOSED / CONFIRMED / ACTIVE / COMPLETED / CANCELLED"],
];
modelSheet.addRows(modelData);
// Style the model sheet
modelSheet.getColumn(1).width = 28;
modelSheet.getColumn(2).width = 22;
modelSheet.getColumn(3).width = 12;
modelSheet.getColumn(4).width = 35;
modelSheet.getColumn(5).width = 72;
// Style title row
const titleCell = modelSheet.getCell(1, 1);
titleCell.font = { bold: true, size: 14, color: { argb: "FF4F46E5" } };
titleCell.value = "Planarchy Data Model — Field Reference";
// Style section headers and field headers
const sectionRows = [3, 17, 31];
const fieldHeaderRows = [4, 18, 32];
for (const r of sectionRows) {
for (let c = 1; c <= 5; c++) {
const cell = modelSheet.getCell(r, c);
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF4F46E5" } };
cell.font = { bold: true, color: { argb: "FFFFFFFF" }, size: 11 };
}
}
for (const r of fieldHeaderRows) {
for (let c = 1; c <= 5; c++) {
const cell = modelSheet.getCell(r, c);
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE0E7FF" } };
cell.font = { bold: true, color: { argb: "FF3730A3" }, size: 10 };
}
}
// ─── Save ────────────────────────────────────────────────────────────────
await workbook.xlsx.writeFile(EXCEL_PATH);
console.log(`✅ Excel updated: ${EXCEL_PATH}`);
console.log(" - EID_Informationen: added Display Name, Email, Skills, Notes columns");
console.log(" - Projektinfomartionen: added Start Date, End Date, Status, Notes columns");
console.log(" - Added new sheet: 'Planarchy Data Model' (field reference)");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@planarchy/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}