chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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
@@ -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;
|
||||
@@ -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());
|
||||
@@ -0,0 +1,2 @@
|
||||
export { prisma, default } from "./client.js";
|
||||
export * from "@prisma/client";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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 Mon–Fri 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" }, // Mon–Fri 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" }, // Mon–Fri
|
||||
},
|
||||
2026: {
|
||||
spring: { start: "2026-03-30", end: "2026-04-03" }, // Mon–Fri 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" }, // Mon–Fri
|
||||
},
|
||||
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
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@planarchy/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user