388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
/**
|
|
* Generate samples/CapaKrakenExamples.xlsx from the live database.
|
|
*
|
|
* Run from repo root:
|
|
* DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken \
|
|
* pnpm --filter @capakraken/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 [assignments, demandRequirements] = await Promise.all([
|
|
prisma.assignment.findMany({
|
|
orderBy: [{ project: { startDate: "asc" } }, { resource: { displayName: "asc" } }],
|
|
include: {
|
|
resource: { select: { eid: true, displayName: true, chapter: true } },
|
|
project: { select: { shortCode: true, name: true, startDate: true } },
|
|
},
|
|
}),
|
|
prisma.demandRequirement.findMany({
|
|
orderBy: [{ project: { startDate: "asc" } }, { role: "asc" }],
|
|
include: {
|
|
project: { select: { shortCode: true, name: true, startDate: true } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const allocations = [
|
|
...assignments.map((assignment) => ({
|
|
projectStartDate: assignment.project.startDate,
|
|
projectCode: assignment.project.shortCode,
|
|
projectName: assignment.project.name,
|
|
eid: assignment.resource?.eid ?? "",
|
|
resourceName: assignment.resource?.displayName ?? "",
|
|
chapter: assignment.resource?.chapter ?? "",
|
|
role: assignment.role ?? "",
|
|
startDate: assignment.startDate,
|
|
endDate: assignment.endDate,
|
|
hoursPerDay: assignment.hoursPerDay,
|
|
dailyCostCents: assignment.dailyCostCents,
|
|
status: assignment.status,
|
|
})),
|
|
...demandRequirements.map((demandRequirement) => ({
|
|
projectStartDate: demandRequirement.project.startDate,
|
|
projectCode: demandRequirement.project.shortCode,
|
|
projectName: demandRequirement.project.name,
|
|
eid: "",
|
|
resourceName: "",
|
|
chapter: "",
|
|
role: demandRequirement.role ?? "",
|
|
startDate: demandRequirement.startDate,
|
|
endDate: demandRequirement.endDate,
|
|
hoursPerDay: demandRequirement.hoursPerDay,
|
|
dailyCostCents: 0,
|
|
status: demandRequirement.status,
|
|
})),
|
|
].sort((left, right) => {
|
|
const startDiff = left.projectStartDate.getTime() - right.projectStartDate.getTime();
|
|
if (startDiff !== 0) {
|
|
return startDiff;
|
|
}
|
|
|
|
const resourceDiff = left.resourceName.localeCompare(right.resourceName);
|
|
if (resourceDiff !== 0) {
|
|
return resourceDiff;
|
|
}
|
|
|
|
return left.role.localeCompare(right.role);
|
|
});
|
|
|
|
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.projectCode,
|
|
alloc.projectName,
|
|
alloc.eid,
|
|
alloc.resourceName,
|
|
alloc.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, assignmentCount, demandRequirementCount] = await Promise.all([
|
|
prisma.resource.count(),
|
|
prisma.project.count(),
|
|
prisma.assignment.count(),
|
|
prisma.demandRequirement.count(),
|
|
]);
|
|
const allocationCount = assignmentCount + demandRequirementCount;
|
|
|
|
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 = "CapaKraken — 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 = "CapaKraken";
|
|
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/CapaKrakenExamples.xlsx");
|
|
await wb.xlsx.writeFile(outPath);
|
|
console.log(`Excel written to: ${outPath}`);
|
|
}
|
|
|
|
main()
|
|
.catch((e) => { console.error(e); process.exit(1); })
|
|
.finally(() => prisma.$disconnect());
|