/** * 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());