refactor(insights): share workbook export and ai defaults

This commit is contained in:
2026-03-31 22:53:53 +02:00
parent 05eeaab3f7
commit 160ba99b5c
8 changed files with 272 additions and 61 deletions
@@ -70,6 +70,12 @@ function entityLink(type: string, entityId: string): string {
return `/projects/${entityId}`; return `/projects/${entityId}`;
} }
type ProjectOption = {
id: string;
name: string;
shortCode: string | null;
};
// ─── Main component ────────────────────────────────────────────────────────── // ─── Main component ──────────────────────────────────────────────────────────
export function InsightsPanel() { export function InsightsPanel() {
@@ -111,7 +117,7 @@ export function InsightsPanel() {
}); });
const anomalies = anomaliesQuery.data ?? []; const anomalies = anomaliesQuery.data ?? [];
const projects = projectsQuery.data?.projects ?? []; const projects: ProjectOption[] = (projectsQuery.data?.projects ?? []) as ProjectOption[];
// Filter anomalies // Filter anomalies
const filteredAnomalies = narrativeFilter const filteredAnomalies = narrativeFilter
@@ -6,7 +6,7 @@ import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/comp
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js"; import { useTableSort } from "~/hooks/useTableSort.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import * as XLSX from "xlsx"; import { downloadWorkbook } from "~/lib/workbook-export.js";
const SkillDistributionChart = dynamic( const SkillDistributionChart = dynamic(
() => import("~/components/analytics/SkillDistributionChart.js"), () => import("~/components/analytics/SkillDistributionChart.js"),
@@ -60,18 +60,17 @@ export function SkillsAnalytics() {
async function exportXlsx() { async function exportXlsx() {
if (!data) return; if (!data) return;
const XLSX = await import("xlsx"); const rows = [
const rows = data.aggregated.map((e) => ({ ["Skill", "Category", "# Resources", "Avg Proficiency", "Chapters"],
Skill: e.skill, ...data.aggregated.map((entry) => [
Category: e.category, entry.skill,
"# Resources": e.count, entry.category,
"Avg Proficiency": e.avgProficiency, entry.count,
Chapters: e.chapters.join(", "), entry.avgProficiency,
})); entry.chapters.join(", "),
const ws = XLSX.utils.json_to_sheet(rows); ]),
const wb = XLSX.utils.book_new(); ];
XLSX.utils.book_append_sheet(wb, ws, "Skills"); await downloadWorkbook(`skills-analytics-${Date.now()}.xlsx`, "Skills", rows);
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
} }
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill); const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
@@ -4,6 +4,7 @@ import { useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js"; import { useTableSort } from "~/hooks/useTableSort.js";
import { downloadWorkbook } from "~/lib/workbook-export.js";
import { ProficiencyBadge } from "./shared.js"; import { ProficiencyBadge } from "./shared.js";
const SkillDistributionChart = dynamic( const SkillDistributionChart = dynamic(
@@ -44,18 +45,17 @@ export function OverviewTab({ aggregated, categories, totalResources, totalSkill
const gapCount = aggregated.filter((e) => e.count < 3 && e.avgProficiency >= 3).length; const gapCount = aggregated.filter((e) => e.count < 3 && e.avgProficiency >= 3).length;
async function exportXlsx() { async function exportXlsx() {
const XLSX = await import("xlsx"); const rows = [
const rows = sorted.map((e) => ({ ["Skill", "Category", "# Resources", "Avg Proficiency", "Chapters"],
Skill: e.skill, ...sorted.map((entry) => [
Category: e.category, entry.skill,
"# Resources": e.count, entry.category,
"Avg Proficiency": e.avgProficiency, entry.count,
Chapters: e.chapters.join(", "), entry.avgProficiency,
})); entry.chapters.join(", "),
const ws = XLSX.utils.json_to_sheet(rows); ]),
const wb = XLSX.utils.book_new(); ];
XLSX.utils.book_append_sheet(wb, ws, "Skills Overview"); await downloadWorkbook(`skills-overview-${Date.now()}.xlsx`, "Skills Overview", rows);
XLSX.writeFile(wb, `skills-overview-${Date.now()}.xlsx`);
} }
return ( return (
@@ -3,6 +3,7 @@
import { useState, useId } from "react"; import { useState, useId } from "react";
import Link from "next/link"; import Link from "next/link";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { downloadWorkbook } from "~/lib/workbook-export.js";
import { ProficiencyBadge, PROFICIENCY_LABELS, proficiencyClasses } from "./shared.js"; import { ProficiencyBadge, PROFICIENCY_LABELS, proficiencyClasses } from "./shared.js";
type SkillRule = { skill: string; minProficiency: number }; type SkillRule = { skill: string; minProficiency: number };
@@ -32,17 +33,16 @@ export function PeopleFinderTab({ allSkillNames, allChapters }: PeopleFinderTabP
async function exportXlsx() { async function exportXlsx() {
if (!results || results.length === 0) return; if (!results || results.length === 0) return;
const XLSX = await import("xlsx"); const rows = [
const rows = results.map((p) => ({ ["Name", "EID", "Chapter", "Matched Skills"],
Name: p.displayName, ...results.map((person) => [
EID: p.eid ?? "", person.displayName,
Chapter: p.chapter ?? "", person.eid ?? "",
"Matched Skills": p.matchedSkills.map((s) => `${s.skill} (${s.proficiency})`).join(", "), person.chapter ?? "",
})); person.matchedSkills.map((skill) => `${skill.skill} (${skill.proficiency})`).join(", "),
const ws = XLSX.utils.json_to_sheet(rows); ]),
const wb = XLSX.utils.book_new(); ];
XLSX.utils.book_append_sheet(wb, ws, "People Finder"); await downloadWorkbook(`people-finder-${Date.now()}.xlsx`, "People Finder", rows);
XLSX.writeFile(wb, `people-finder-${Date.now()}.xlsx`);
} }
return ( return (
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
createWorkbookArrayBuffer,
createWorkbookArrayBufferFromSheets,
} from "./workbook-export.js";
describe("workbook export helpers", () => {
it("writes a single-sheet workbook with primitive values", async () => {
const buffer = await createWorkbookArrayBuffer("Skills", [
["Skill", "Count", "Active"],
["TypeScript", 4, true],
["Planning", 2, false],
]);
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(Buffer.from(buffer));
const worksheet = workbook.getWorksheet("Skills");
expect(worksheet).toBeDefined();
expect(worksheet?.getRow(1).values).toEqual([, "Skill", "Count", "Active"]);
expect(worksheet?.getRow(2).values).toEqual([, "TypeScript", 4, true]);
expect(worksheet?.getRow(3).values).toEqual([, "Planning", 2, false]);
});
it("writes all provided sheets into the workbook", async () => {
const buffer = await createWorkbookArrayBufferFromSheets([
{
name: "Overview",
rows: [["Metric", "Value"], ["Resources", 12]],
},
{
name: "People Finder",
rows: [["Name", "Skills"], ["Peter Parker", "Staffing, Forecasting"]],
},
]);
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(Buffer.from(buffer));
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([
"Overview",
"People Finder",
]);
expect(workbook.getWorksheet("Overview")?.getRow(2).values).toEqual([, "Resources", 12]);
expect(workbook.getWorksheet("People Finder")?.getRow(2).values).toEqual([
,
"Peter Parker",
"Staffing, Forecasting",
]);
});
});
+65
View File
@@ -0,0 +1,65 @@
type ExcelJsModule = typeof import("exceljs");
type WorkbookCellValue = boolean | Date | number | string | null | undefined;
type WorkbookRow = WorkbookCellValue[];
type WorkbookSheet = {
name: string;
rows: WorkbookRow[];
};
let _excelJs: ExcelJsModule | null = null;
async function getExcelJS() {
if (!_excelJs) {
_excelJs = await import("exceljs");
}
return _excelJs;
}
export async function createWorkbookArrayBuffer(
sheetName: string,
rows: WorkbookRow[],
): Promise<ArrayBuffer> {
return createWorkbookArrayBufferFromSheets([{ name: sheetName, rows }]);
}
export async function createWorkbookArrayBufferFromSheets(
sheets: WorkbookSheet[],
): Promise<ArrayBuffer> {
const ExcelJS = await getExcelJS();
const workbook = new ExcelJS.Workbook();
for (const sheet of sheets) {
const worksheet = workbook.addWorksheet(sheet.name);
for (const row of sheet.rows) {
worksheet.addRow(row.map((value) => value ?? ""));
}
}
const buffer = await workbook.xlsx.writeBuffer();
return buffer as ArrayBuffer;
}
export async function downloadWorkbook(
fileName: string,
sheetName: string,
rows: WorkbookRow[],
): Promise<void> {
return downloadWorkbookSheets(fileName, [{ name: sheetName, rows }]);
}
export async function downloadWorkbookSheets(
fileName: string,
sheets: WorkbookSheet[],
): Promise<void> {
const buffer = await createWorkbookArrayBufferFromSheets(sheets);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
@@ -1,3 +1,4 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -14,7 +15,7 @@ vi.mock("../ai-client.js", async (importOriginal) => {
}, },
}, },
})), })),
isAiConfigured: vi.fn().mockReturnValue(true), isAiConfigured: vi.fn((settings: unknown) => settings != null),
loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn) => fn()), loggedAiCall: vi.fn(async (_provider, _model, _promptLength, fn) => fn()),
parseAiError: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error)), parseAiError: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error)),
}; };
@@ -99,7 +100,7 @@ describe("insights procedure support", () => {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
id: "singleton", id: "singleton",
aiProvider: "openai", aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini", azureOpenAiDeployment: DEFAULT_OPENAI_MODEL,
aiMaxCompletionTokens: 220, aiMaxCompletionTokens: 220,
aiTemperature: 0.4, aiTemperature: 0.4,
}), }),
@@ -109,15 +110,15 @@ describe("insights procedure support", () => {
); );
expect(isAiConfigured).toHaveBeenCalledWith( expect(isAiConfigured).toHaveBeenCalledWith(
expect.objectContaining({ azureOpenAiDeployment: "gpt-4o-mini" }), expect.objectContaining({ azureOpenAiDeployment: DEFAULT_OPENAI_MODEL }),
); );
expect(createAiClient).toHaveBeenCalledWith( expect(createAiClient).toHaveBeenCalledWith(
expect.objectContaining({ azureOpenAiDeployment: "gpt-4o-mini" }), expect.objectContaining({ azureOpenAiDeployment: DEFAULT_OPENAI_MODEL }),
); );
expect(loggedAiCall).toHaveBeenCalledOnce(); expect(loggedAiCall).toHaveBeenCalledOnce();
expect(aiCompletionCreate).toHaveBeenCalledWith( expect(aiCompletionCreate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
model: "gpt-4o-mini", model: DEFAULT_OPENAI_MODEL,
max_completion_tokens: 220, max_completion_tokens: 220,
temperature: 0.4, temperature: 0.4,
messages: expect.arrayContaining([ messages: expect.arrayContaining([
@@ -148,6 +149,83 @@ describe("insights procedure support", () => {
} }
}); });
it("fails when AI settings are missing", async () => {
await expect(
generateProjectNarrative(
createContext({
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Apollo",
shortCode: "APO",
status: "ACTIVE",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-04-30T00:00:00.000Z"),
budgetCents: 200_000_00,
dynamicFields: null,
demandRequirements: [],
assignments: [],
}),
update: vi.fn(),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
}),
{ projectId: "project_1" },
),
).rejects.toEqual(
expect.objectContaining<Partial<TRPCError>>({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.",
}),
);
expect(createAiClient).not.toHaveBeenCalled();
expect(loggedAiCall).not.toHaveBeenCalled();
});
it("fails when AI configuration is incomplete", async () => {
vi.mocked(isAiConfigured).mockReturnValue(false);
await expect(
generateProjectNarrative(
createContext({
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Apollo",
shortCode: "APO",
status: "ACTIVE",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-04-30T00:00:00.000Z"),
budgetCents: 200_000_00,
dynamicFields: null,
demandRequirements: [],
assignments: [],
}),
update: vi.fn(),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
id: "singleton",
aiProvider: "openai",
azureOpenAiDeployment: null,
}),
},
}),
{ projectId: "project_1" },
),
).rejects.toEqual(
expect.objectContaining<Partial<TRPCError>>({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.",
}),
);
expect(createAiClient).not.toHaveBeenCalled();
expect(aiCompletionCreate).not.toHaveBeenCalled();
});
it("returns the cached narrative for a project", async () => { it("returns the cached narrative for a project", async () => {
const result = await getCachedNarrative( const result = await getCachedNarrative(
createContext({ createContext({
@@ -1,4 +1,9 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type {
ChatCompletion,
ChatCompletionCreateParamsNonStreaming,
} from "openai/resources/chat/completions/completions";
import { z } from "zod"; import { z } from "zod";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js"; import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import type { TRPCContext } from "../trpc.js"; import type { TRPCContext } from "../trpc.js";
@@ -73,13 +78,15 @@ export async function generateProjectNarrative(
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
} }
if (!isAiConfigured(settings)) { if (!settings || !isAiConfigured(settings)) {
throw new TRPCError({ throw new TRPCError({
code: "PRECONDITION_FAILED", code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.", message: "AI is not configured. Please set credentials in Admin → Settings.",
}); });
} }
const configuredSettings = settings;
const now = new Date(); const now = new Date();
const totalDays = countBusinessDays(project.startDate, project.endDate); const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays( const elapsedDays = countBusinessDays(
@@ -125,27 +132,30 @@ export async function generateProjectNarrative(
${dataContext}`; ${dataContext}`;
const client = createAiClient(settings); const client = createAiClient(configuredSettings);
const model = settings.azureOpenAiDeployment; const model = configuredSettings.azureOpenAiDeployment ?? DEFAULT_OPENAI_MODEL;
const maxTokens = settings.aiMaxCompletionTokens ?? 300; const maxTokens = configuredSettings.aiMaxCompletionTokens ?? 300;
const temperature = settings.aiTemperature ?? 1; const temperature = configuredSettings.aiTemperature ?? 1;
const provider = settings.aiProvider ?? "openai"; const provider = configuredSettings.aiProvider ?? "openai";
let narrative = ""; let narrative = "";
try { try {
const completion = await loggedAiCall(provider, model, prompt.length, () => const completionRequest: ChatCompletionCreateParamsNonStreaming = {
client.chat.completions.create({ messages: [
messages: [ {
{ role: "system",
role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented.",
content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented.", },
}, { role: "user", content: prompt },
{ role: "user", content: prompt }, ],
], max_completion_tokens: maxTokens,
max_completion_tokens: maxTokens, model,
model, stream: false,
...(temperature !== 1 ? { temperature } : {}), ...(temperature !== 1 ? { temperature } : {}),
}), };
const completion = await loggedAiCall<ChatCompletion>(provider, model, prompt.length, () =>
client.chat.completions.create(completionRequest),
); );
narrative = completion.choices[0]?.message?.content?.trim() ?? ""; narrative = completion.choices[0]?.message?.content?.trim() ?? "";
} catch (error) { } catch (error) {