diff --git a/apps/web/src/components/analytics/InsightsPanel.tsx b/apps/web/src/components/analytics/InsightsPanel.tsx index 03f990c..d081ba8 100644 --- a/apps/web/src/components/analytics/InsightsPanel.tsx +++ b/apps/web/src/components/analytics/InsightsPanel.tsx @@ -70,6 +70,12 @@ function entityLink(type: string, entityId: string): string { return `/projects/${entityId}`; } +type ProjectOption = { + id: string; + name: string; + shortCode: string | null; +}; + // ─── Main component ────────────────────────────────────────────────────────── export function InsightsPanel() { @@ -111,7 +117,7 @@ export function InsightsPanel() { }); const anomalies = anomaliesQuery.data ?? []; - const projects = projectsQuery.data?.projects ?? []; + const projects: ProjectOption[] = (projectsQuery.data?.projects ?? []) as ProjectOption[]; // Filter anomalies const filteredAnomalies = narrativeFilter diff --git a/apps/web/src/components/analytics/SkillsAnalytics.tsx b/apps/web/src/components/analytics/SkillsAnalytics.tsx index a941156..39b96f3 100644 --- a/apps/web/src/components/analytics/SkillsAnalytics.tsx +++ b/apps/web/src/components/analytics/SkillsAnalytics.tsx @@ -6,7 +6,7 @@ import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/comp import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { trpc } from "~/lib/trpc/client.js"; -import * as XLSX from "xlsx"; +import { downloadWorkbook } from "~/lib/workbook-export.js"; const SkillDistributionChart = dynamic( () => import("~/components/analytics/SkillDistributionChart.js"), @@ -60,18 +60,17 @@ export function SkillsAnalytics() { async function exportXlsx() { if (!data) return; - const XLSX = await import("xlsx"); - const rows = data.aggregated.map((e) => ({ - Skill: e.skill, - Category: e.category, - "# Resources": e.count, - "Avg Proficiency": e.avgProficiency, - Chapters: e.chapters.join(", "), - })); - const ws = XLSX.utils.json_to_sheet(rows); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Skills"); - XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`); + const rows = [ + ["Skill", "Category", "# Resources", "Avg Proficiency", "Chapters"], + ...data.aggregated.map((entry) => [ + entry.skill, + entry.category, + entry.count, + entry.avgProficiency, + entry.chapters.join(", "), + ]), + ]; + await downloadWorkbook(`skills-analytics-${Date.now()}.xlsx`, "Skills", rows); } const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill); diff --git a/apps/web/src/components/analytics/skills/OverviewTab.tsx b/apps/web/src/components/analytics/skills/OverviewTab.tsx index 5109054..c422bca 100644 --- a/apps/web/src/components/analytics/skills/OverviewTab.tsx +++ b/apps/web/src/components/analytics/skills/OverviewTab.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import dynamic from "next/dynamic"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; +import { downloadWorkbook } from "~/lib/workbook-export.js"; import { ProficiencyBadge } from "./shared.js"; 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; async function exportXlsx() { - const XLSX = await import("xlsx"); - const rows = sorted.map((e) => ({ - Skill: e.skill, - Category: e.category, - "# Resources": e.count, - "Avg Proficiency": e.avgProficiency, - Chapters: e.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"); - XLSX.writeFile(wb, `skills-overview-${Date.now()}.xlsx`); + const rows = [ + ["Skill", "Category", "# Resources", "Avg Proficiency", "Chapters"], + ...sorted.map((entry) => [ + entry.skill, + entry.category, + entry.count, + entry.avgProficiency, + entry.chapters.join(", "), + ]), + ]; + await downloadWorkbook(`skills-overview-${Date.now()}.xlsx`, "Skills Overview", rows); } return ( diff --git a/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx b/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx index 99e1bc3..7c3869d 100644 --- a/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx +++ b/apps/web/src/components/analytics/skills/PeopleFinderTab.tsx @@ -3,6 +3,7 @@ import { useState, useId } from "react"; import Link from "next/link"; import { trpc } from "~/lib/trpc/client.js"; +import { downloadWorkbook } from "~/lib/workbook-export.js"; import { ProficiencyBadge, PROFICIENCY_LABELS, proficiencyClasses } from "./shared.js"; type SkillRule = { skill: string; minProficiency: number }; @@ -32,17 +33,16 @@ export function PeopleFinderTab({ allSkillNames, allChapters }: PeopleFinderTabP async function exportXlsx() { if (!results || results.length === 0) return; - const XLSX = await import("xlsx"); - const rows = results.map((p) => ({ - Name: p.displayName, - EID: p.eid ?? "", - Chapter: p.chapter ?? "", - "Matched Skills": p.matchedSkills.map((s) => `${s.skill} (${s.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"); - XLSX.writeFile(wb, `people-finder-${Date.now()}.xlsx`); + const rows = [ + ["Name", "EID", "Chapter", "Matched Skills"], + ...results.map((person) => [ + person.displayName, + person.eid ?? "", + person.chapter ?? "", + person.matchedSkills.map((skill) => `${skill.skill} (${skill.proficiency})`).join(", "), + ]), + ]; + await downloadWorkbook(`people-finder-${Date.now()}.xlsx`, "People Finder", rows); } return ( diff --git a/apps/web/src/lib/workbook-export.test.ts b/apps/web/src/lib/workbook-export.test.ts new file mode 100644 index 0000000..da83df2 --- /dev/null +++ b/apps/web/src/lib/workbook-export.test.ts @@ -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", + ]); + }); +}); diff --git a/apps/web/src/lib/workbook-export.ts b/apps/web/src/lib/workbook-export.ts new file mode 100644 index 0000000..4ef12fa --- /dev/null +++ b/apps/web/src/lib/workbook-export.ts @@ -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 { + return createWorkbookArrayBufferFromSheets([{ name: sheetName, rows }]); +} + +export async function createWorkbookArrayBufferFromSheets( + sheets: WorkbookSheet[], +): Promise { + 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 { + return downloadWorkbookSheets(fileName, [{ name: sheetName, rows }]); +} + +export async function downloadWorkbookSheets( + fileName: string, + sheets: WorkbookSheet[], +): Promise { + 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); +} diff --git a/packages/api/src/__tests__/insights-procedure-support.test.ts b/packages/api/src/__tests__/insights-procedure-support.test.ts index 786f132..9e103f1 100644 --- a/packages/api/src/__tests__/insights-procedure-support.test.ts +++ b/packages/api/src/__tests__/insights-procedure-support.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; 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()), parseAiError: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error)), }; @@ -99,7 +100,7 @@ describe("insights procedure support", () => { findUnique: vi.fn().mockResolvedValue({ id: "singleton", aiProvider: "openai", - azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiDeployment: DEFAULT_OPENAI_MODEL, aiMaxCompletionTokens: 220, aiTemperature: 0.4, }), @@ -109,15 +110,15 @@ describe("insights procedure support", () => { ); expect(isAiConfigured).toHaveBeenCalledWith( - expect.objectContaining({ azureOpenAiDeployment: "gpt-4o-mini" }), + expect.objectContaining({ azureOpenAiDeployment: DEFAULT_OPENAI_MODEL }), ); expect(createAiClient).toHaveBeenCalledWith( - expect.objectContaining({ azureOpenAiDeployment: "gpt-4o-mini" }), + expect.objectContaining({ azureOpenAiDeployment: DEFAULT_OPENAI_MODEL }), ); expect(loggedAiCall).toHaveBeenCalledOnce(); expect(aiCompletionCreate).toHaveBeenCalledWith( expect.objectContaining({ - model: "gpt-4o-mini", + model: DEFAULT_OPENAI_MODEL, max_completion_tokens: 220, temperature: 0.4, 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>({ + 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>({ + 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 () => { const result = await getCachedNarrative( createContext({ diff --git a/packages/api/src/router/insights-procedure-support.ts b/packages/api/src/router/insights-procedure-support.ts index 0f40b9e..1cf3773 100644 --- a/packages/api/src/router/insights-procedure-support.ts +++ b/packages/api/src/router/insights-procedure-support.ts @@ -1,4 +1,9 @@ +import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; +import type { + ChatCompletion, + ChatCompletionCreateParamsNonStreaming, +} from "openai/resources/chat/completions/completions"; import { z } from "zod"; import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.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" }); } - if (!isAiConfigured(settings)) { + if (!settings || !isAiConfigured(settings)) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "AI is not configured. Please set credentials in Admin → Settings.", }); } + const configuredSettings = settings; + const now = new Date(); const totalDays = countBusinessDays(project.startDate, project.endDate); const elapsedDays = countBusinessDays( @@ -125,27 +132,30 @@ export async function generateProjectNarrative( ${dataContext}`; - const client = createAiClient(settings); - const model = settings.azureOpenAiDeployment; - const maxTokens = settings.aiMaxCompletionTokens ?? 300; - const temperature = settings.aiTemperature ?? 1; - const provider = settings.aiProvider ?? "openai"; + const client = createAiClient(configuredSettings); + const model = configuredSettings.azureOpenAiDeployment ?? DEFAULT_OPENAI_MODEL; + const maxTokens = configuredSettings.aiMaxCompletionTokens ?? 300; + const temperature = configuredSettings.aiTemperature ?? 1; + const provider = configuredSettings.aiProvider ?? "openai"; let narrative = ""; try { - const completion = await loggedAiCall(provider, model, prompt.length, () => - client.chat.completions.create({ - messages: [ - { - role: "system", - content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented.", - }, - { role: "user", content: prompt }, - ], - max_completion_tokens: maxTokens, - model, - ...(temperature !== 1 ? { temperature } : {}), - }), + const completionRequest: ChatCompletionCreateParamsNonStreaming = { + messages: [ + { + role: "system", + content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented.", + }, + { role: "user", content: prompt }, + ], + max_completion_tokens: maxTokens, + model, + stream: false, + ...(temperature !== 1 ? { temperature } : {}), + }; + + const completion = await loggedAiCall(provider, model, prompt.length, () => + client.chat.completions.create(completionRequest), ); narrative = completion.choices[0]?.message?.content?.trim() ?? ""; } catch (error) {