refactor(api): extract dashboard procedures
This commit is contained in:
@@ -17,13 +17,13 @@ Done
|
|||||||
- `import-export`
|
- `import-export`
|
||||||
- `chargeability-report`
|
- `chargeability-report`
|
||||||
- `notification`
|
- `notification`
|
||||||
|
- `dashboard`
|
||||||
|
|
||||||
Ready next
|
Ready next
|
||||||
- none in the conflict-safe backlog
|
- none in the conflict-safe backlog
|
||||||
|
|
||||||
Deferred or blocked
|
Deferred or blocked
|
||||||
- `assistant-tools`
|
- `assistant-tools`
|
||||||
- `dashboard`
|
|
||||||
- `entitlement`
|
- `entitlement`
|
||||||
- `resource-read-shared`
|
- `resource-read-shared`
|
||||||
- `resource-summary-read`
|
- `resource-summary-read`
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getDashboardOverview: vi.fn(),
|
||||||
|
getDashboardPeakTimes: vi.fn(),
|
||||||
|
getDashboardDemand: vi.fn(),
|
||||||
|
getDashboardTopValueResources: vi.fn(),
|
||||||
|
getDashboardChargeabilityOverview: vi.fn(),
|
||||||
|
getDashboardBudgetForecast: vi.fn(),
|
||||||
|
getDashboardSkillGaps: vi.fn(),
|
||||||
|
getDashboardSkillGapSummary: vi.fn(),
|
||||||
|
getDashboardProjectHealth: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../lib/cache.js", () => ({
|
||||||
|
cacheGet: vi.fn().mockResolvedValue(null),
|
||||||
|
cacheSet: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/anonymization.js", () => ({
|
||||||
|
anonymizeResources: vi.fn((resources: unknown[]) => resources.map((resource) => ({ ...resource, anonymized: true }))),
|
||||||
|
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDashboardChargeabilityOverview,
|
||||||
|
getDashboardDemand,
|
||||||
|
getDashboardOverview,
|
||||||
|
getDashboardPeakTimes,
|
||||||
|
getDashboardTopValueResources,
|
||||||
|
} from "@capakraken/application";
|
||||||
|
import { anonymizeResources } from "../lib/anonymization.js";
|
||||||
|
import {
|
||||||
|
getDashboardChargeabilityOverviewRead,
|
||||||
|
getDashboardDetail,
|
||||||
|
getDashboardStatisticsDetail,
|
||||||
|
} from "../router/dashboard-procedure-support.js";
|
||||||
|
|
||||||
|
function createContext() {
|
||||||
|
return {
|
||||||
|
db: {} as never,
|
||||||
|
session: {
|
||||||
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: SystemRole.CONTROLLER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dashboard procedure support", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives statistics detail from the canonical overview", async () => {
|
||||||
|
vi.mocked(getDashboardOverview).mockResolvedValue({
|
||||||
|
totalResources: 12,
|
||||||
|
activeResources: 10,
|
||||||
|
inactiveResources: 2,
|
||||||
|
totalProjects: 7,
|
||||||
|
activeProjects: 4,
|
||||||
|
inactiveProjects: 3,
|
||||||
|
totalAllocations: 21,
|
||||||
|
activeAllocations: 18,
|
||||||
|
cancelledAllocations: 3,
|
||||||
|
approvedVacations: 6,
|
||||||
|
totalEstimates: 9,
|
||||||
|
budgetSummary: {
|
||||||
|
totalBudgetCents: 1_234_56,
|
||||||
|
totalCostCents: 654_32,
|
||||||
|
avgUtilizationPercent: 53,
|
||||||
|
},
|
||||||
|
budgetBasis: {
|
||||||
|
remainingBudgetCents: 58_024,
|
||||||
|
budgetedProjects: 5,
|
||||||
|
unbudgetedProjects: 2,
|
||||||
|
trackedAssignmentCount: 18,
|
||||||
|
windowStart: null,
|
||||||
|
windowEnd: null,
|
||||||
|
},
|
||||||
|
projectsByStatus: [
|
||||||
|
{ status: "ACTIVE", count: 4 },
|
||||||
|
{ status: "DRAFT", count: 2 },
|
||||||
|
{ status: "DONE", count: 1 },
|
||||||
|
],
|
||||||
|
chapterUtilization: [
|
||||||
|
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
|
||||||
|
{ chapter: "Compositing", resourceCount: 3, avgChargeabilityTarget: 74 },
|
||||||
|
],
|
||||||
|
recentActivity: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getDashboardStatisticsDetail(createContext());
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
activeResources: 10,
|
||||||
|
totalProjects: 7,
|
||||||
|
activeProjects: 4,
|
||||||
|
totalAllocations: 21,
|
||||||
|
approvedVacations: 6,
|
||||||
|
totalEstimates: 9,
|
||||||
|
totalBudget: "1.234,56 EUR",
|
||||||
|
projectsByStatus: {
|
||||||
|
ACTIVE: 4,
|
||||||
|
DRAFT: 2,
|
||||||
|
DONE: 1,
|
||||||
|
},
|
||||||
|
topChapters: [
|
||||||
|
{ chapter: "CGI", count: 5 },
|
||||||
|
{ chapter: "Compositing", count: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the assistant-facing dashboard detail payload", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-15T00:00:00.000Z"));
|
||||||
|
vi.mocked(getDashboardOverview).mockResolvedValue({
|
||||||
|
totalResources: 12,
|
||||||
|
activeResources: 10,
|
||||||
|
inactiveResources: 2,
|
||||||
|
totalProjects: 7,
|
||||||
|
activeProjects: 4,
|
||||||
|
inactiveProjects: 3,
|
||||||
|
totalAllocations: 21,
|
||||||
|
activeAllocations: 18,
|
||||||
|
cancelledAllocations: 3,
|
||||||
|
approvedVacations: 6,
|
||||||
|
totalEstimates: 9,
|
||||||
|
budgetSummary: {
|
||||||
|
totalBudgetCents: 1_234_56,
|
||||||
|
totalCostCents: 654_32,
|
||||||
|
avgUtilizationPercent: 53,
|
||||||
|
},
|
||||||
|
budgetBasis: {
|
||||||
|
remainingBudgetCents: 58_024,
|
||||||
|
budgetedProjects: 5,
|
||||||
|
unbudgetedProjects: 2,
|
||||||
|
trackedAssignmentCount: 18,
|
||||||
|
windowStart: "2026-03-01T00:00:00.000Z",
|
||||||
|
windowEnd: "2026-06-30T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
projectsByStatus: [],
|
||||||
|
chapterUtilization: [
|
||||||
|
{ chapter: "CGI", resourceCount: 5, avgChargeabilityTarget: 78 },
|
||||||
|
],
|
||||||
|
recentActivity: [],
|
||||||
|
});
|
||||||
|
vi.mocked(getDashboardPeakTimes).mockResolvedValue([
|
||||||
|
{ period: "2026-03", totalHours: 160.34, capacityHours: 200.12, utilizationPct: 80 },
|
||||||
|
]);
|
||||||
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
eid: "E-001",
|
||||||
|
displayName: "Ada Lovelace",
|
||||||
|
chapter: "CGI",
|
||||||
|
lcrCents: 12345,
|
||||||
|
valueScore: 98,
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(getDashboardDemand).mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "Apollo",
|
||||||
|
shortCode: "APO",
|
||||||
|
requiredFTEs: 3,
|
||||||
|
resourceCount: 1,
|
||||||
|
allocatedHours: 80,
|
||||||
|
derivation: { calendarLocations: [{ countryCode: "DE" }] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getDashboardDetail(createContext(), { section: "all" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
peakTimes: [
|
||||||
|
{
|
||||||
|
month: "2026-03",
|
||||||
|
totalHours: 160.3,
|
||||||
|
totalHoursPerDay: 160.3,
|
||||||
|
capacityHours: 200.1,
|
||||||
|
utilizationPct: 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
topResources: [
|
||||||
|
{
|
||||||
|
name: "Ada Lovelace",
|
||||||
|
eid: "E-001",
|
||||||
|
chapter: "CGI",
|
||||||
|
lcr: "123,45 EUR",
|
||||||
|
valueScore: 98,
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
demandPipeline: [
|
||||||
|
{
|
||||||
|
project: "Apollo (APO)",
|
||||||
|
needed: 2,
|
||||||
|
requiredFTEs: 3,
|
||||||
|
allocatedResources: 1,
|
||||||
|
allocatedHours: 80,
|
||||||
|
calendarLocations: [{ countryCode: "DE" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chargeabilityByChapter: [
|
||||||
|
{
|
||||||
|
chapter: "CGI",
|
||||||
|
headcount: 5,
|
||||||
|
avgTarget: "78%",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("anonymizes chargeability overview payloads before returning them", async () => {
|
||||||
|
vi.mocked(getDashboardChargeabilityOverview).mockResolvedValue({
|
||||||
|
avgChargeability: 72,
|
||||||
|
top: [{ id: "res_1" }],
|
||||||
|
watchlist: [{ id: "res_2" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getDashboardChargeabilityOverviewRead(createContext(), {
|
||||||
|
includeProposed: false,
|
||||||
|
topN: 10,
|
||||||
|
watchlistThreshold: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(anonymizeResources).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toEqual({
|
||||||
|
avgChargeability: 72,
|
||||||
|
top: [{ id: "res_1", anonymized: true }],
|
||||||
|
watchlist: [{ id: "res_2", anonymized: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -348,8 +348,28 @@ describe("dashboard router", () => {
|
|||||||
describe("getTopValueResources", () => {
|
describe("getTopValueResources", () => {
|
||||||
it("returns sorted resources with default limit", async () => {
|
it("returns sorted resources with default limit", async () => {
|
||||||
const resources = [
|
const resources = [
|
||||||
{ id: "res_1", displayName: "Alice", valueScore: 95 },
|
{
|
||||||
{ id: "res_2", displayName: "Bob", valueScore: 88 },
|
id: "res_1",
|
||||||
|
eid: "alice",
|
||||||
|
displayName: "Alice",
|
||||||
|
chapter: "Delivery",
|
||||||
|
valueScore: 95,
|
||||||
|
lcrCents: 12_300,
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "res_2",
|
||||||
|
eid: "bob",
|
||||||
|
displayName: "Bob",
|
||||||
|
chapter: "Data",
|
||||||
|
valueScore: 88,
|
||||||
|
lcrCents: 10_800,
|
||||||
|
countryCode: "US",
|
||||||
|
federalState: "CA",
|
||||||
|
metroCityName: "San Francisco",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
|
vi.mocked(getDashboardTopValueResources).mockResolvedValue(resources);
|
||||||
@@ -357,7 +377,7 @@ describe("dashboard router", () => {
|
|||||||
const caller = createControllerCaller({});
|
const caller = createControllerCaller({});
|
||||||
const result = await caller.getTopValueResources({ limit: 10 });
|
const result = await caller.getTopValueResources({ limit: 10 });
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toEqual(resources);
|
||||||
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
|
expect(getDashboardTopValueResources).toHaveBeenCalledWith(
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.objectContaining({ limit: 10 }),
|
expect.objectContaining({ limit: 10 }),
|
||||||
@@ -576,6 +596,9 @@ describe("dashboard router", () => {
|
|||||||
chapter: "Delivery",
|
chapter: "Delivery",
|
||||||
valueScore: 91,
|
valueScore: 91,
|
||||||
lcrCents: 9_500,
|
lcrCents: 9_500,
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Augsburg",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
vi.mocked(getDashboardDemand).mockResolvedValue([
|
vi.mocked(getDashboardDemand).mockResolvedValue([
|
||||||
@@ -620,6 +643,9 @@ describe("dashboard router", () => {
|
|||||||
chapter: "Delivery",
|
chapter: "Delivery",
|
||||||
lcr: "95,00 EUR",
|
lcr: "95,00 EUR",
|
||||||
valueScore: 91,
|
valueScore: 91,
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Augsburg",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
demandPipeline: [
|
demandPipeline: [
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import {
|
||||||
|
getDashboardBudgetForecast,
|
||||||
|
getDashboardChargeabilityOverview,
|
||||||
|
getDashboardDemand,
|
||||||
|
getDashboardOverview,
|
||||||
|
getDashboardPeakTimes,
|
||||||
|
getDashboardProjectHealth,
|
||||||
|
getDashboardSkillGaps,
|
||||||
|
getDashboardSkillGapSummary,
|
||||||
|
getDashboardTopValueResources,
|
||||||
|
} from "@capakraken/application";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
import { cacheGet, cacheSet } from "../lib/cache.js";
|
||||||
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
import type { TRPCContext } from "../trpc.js";
|
||||||
|
|
||||||
|
const DEFAULT_TTL = 60;
|
||||||
|
|
||||||
|
type DashboardProcedureContext = Pick<TRPCContext, "db" | "dbUser" | "session">;
|
||||||
|
|
||||||
|
type TopValueResourceRow = {
|
||||||
|
id: string;
|
||||||
|
eid: string;
|
||||||
|
displayName: string;
|
||||||
|
chapter: string | null;
|
||||||
|
valueScore: number | null;
|
||||||
|
lcrCents: number;
|
||||||
|
countryCode: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardPeakTimesInputSchema = z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
granularity: z.enum(["week", "month"]).default("month"),
|
||||||
|
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardTopValueResourcesInputSchema = z.object({
|
||||||
|
limit: z.number().int().min(1).max(50).default(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardDemandInputSchema = z.object({
|
||||||
|
startDate: z.string().datetime(),
|
||||||
|
endDate: z.string().datetime(),
|
||||||
|
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardDetailInputSchema = z.object({
|
||||||
|
section: z.string().optional().default("all"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardChargeabilityOverviewInputSchema = z.object({
|
||||||
|
includeProposed: z.boolean().default(false),
|
||||||
|
topN: z.number().int().min(1).max(50).default(10),
|
||||||
|
watchlistThreshold: z.number().default(15),
|
||||||
|
countryIds: z.array(z.string()).optional(),
|
||||||
|
departed: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DashboardPeakTimesInput = z.infer<typeof dashboardPeakTimesInputSchema>;
|
||||||
|
type DashboardTopValueResourcesInput = z.infer<typeof dashboardTopValueResourcesInputSchema>;
|
||||||
|
type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
|
||||||
|
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
|
||||||
|
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
|
||||||
|
|
||||||
|
function round1(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
|
||||||
|
const projects = rows
|
||||||
|
.map((project) => {
|
||||||
|
const overall = project.compositeScore;
|
||||||
|
return {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.projectName,
|
||||||
|
shortCode: project.shortCode,
|
||||||
|
status: project.status,
|
||||||
|
overall,
|
||||||
|
budget: project.budgetHealth,
|
||||||
|
staffing: project.staffingHealth,
|
||||||
|
timeline: project.timelineHealth,
|
||||||
|
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.overall - right.overall);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
summary: {
|
||||||
|
healthy: projects.filter((project) => project.rating === "healthy").length,
|
||||||
|
atRisk: projects.filter((project) => project.rating === "at_risk").length,
|
||||||
|
critical: projects.filter((project) => project.rating === "critical").length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
|
||||||
|
return {
|
||||||
|
forecasts: rows.map((forecast) => ({
|
||||||
|
projectId: forecast.projectId ?? null,
|
||||||
|
projectName: forecast.projectName,
|
||||||
|
shortCode: forecast.shortCode,
|
||||||
|
clientId: forecast.clientId,
|
||||||
|
clientName: forecast.clientName,
|
||||||
|
budget: fmtEur(forecast.budgetCents),
|
||||||
|
budgetCents: forecast.budgetCents,
|
||||||
|
spent: fmtEur(forecast.spentCents),
|
||||||
|
spentCents: forecast.spentCents,
|
||||||
|
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
|
||||||
|
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
|
||||||
|
projected: forecast.burnRate > 0
|
||||||
|
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
|
||||||
|
: fmtEur(forecast.spentCents),
|
||||||
|
projectedCents: forecast.burnRate > 0
|
||||||
|
? Math.max(forecast.spentCents, forecast.budgetCents)
|
||||||
|
: forecast.spentCents,
|
||||||
|
burnRate: fmtEur(forecast.burnRate),
|
||||||
|
burnRateCents: forecast.burnRate,
|
||||||
|
utilization: `${forecast.pctUsed}%`,
|
||||||
|
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
|
||||||
|
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
|
||||||
|
calendarLocations: forecast.calendarLocations ?? [],
|
||||||
|
burnStatus: forecast.pctUsed >= 100
|
||||||
|
? "ahead"
|
||||||
|
: forecast.burnRate > 0
|
||||||
|
? "on_track"
|
||||||
|
: "not_started",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
|
||||||
|
return {
|
||||||
|
activeResources: overview.activeResources,
|
||||||
|
totalProjects: overview.totalProjects,
|
||||||
|
activeProjects: overview.activeProjects,
|
||||||
|
totalAllocations: overview.totalAllocations,
|
||||||
|
approvedVacations: overview.approvedVacations,
|
||||||
|
totalEstimates: overview.totalEstimates,
|
||||||
|
totalBudget: overview.budgetSummary.totalBudgetCents > 0
|
||||||
|
? fmtEur(overview.budgetSummary.totalBudgetCents)
|
||||||
|
: "N/A",
|
||||||
|
projectsByStatus: Object.fromEntries(
|
||||||
|
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
|
||||||
|
),
|
||||||
|
topChapters: [...overview.chapterUtilization]
|
||||||
|
.sort((left, right) => right.resourceCount - left.resourceCount)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((chapter) => ({
|
||||||
|
chapter: chapter.chapter,
|
||||||
|
count: chapter.resourceCount,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
|
||||||
|
const cacheKey = "overview";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardOverview(db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPeakTimesCached(
|
||||||
|
db: Parameters<typeof getDashboardPeakTimes>[0],
|
||||||
|
input: DashboardPeakTimesInput,
|
||||||
|
) {
|
||||||
|
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardPeakTimes(db, {
|
||||||
|
startDate: new Date(input.startDate),
|
||||||
|
endDate: new Date(input.endDate),
|
||||||
|
granularity: input.granularity,
|
||||||
|
groupBy: input.groupBy,
|
||||||
|
});
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDemandCached(
|
||||||
|
db: Parameters<typeof getDashboardDemand>[0],
|
||||||
|
input: DashboardDemandInput,
|
||||||
|
) {
|
||||||
|
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardDemand(db, {
|
||||||
|
startDate: new Date(input.startDate),
|
||||||
|
endDate: new Date(input.endDate),
|
||||||
|
groupBy: input.groupBy,
|
||||||
|
});
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTopValueResourcesCached(
|
||||||
|
db: Parameters<typeof getDashboardTopValueResources>[0],
|
||||||
|
input: { limit: number; userRole: string },
|
||||||
|
): Promise<TopValueResourceRow[]> {
|
||||||
|
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
|
||||||
|
const cached = await cacheGet<TopValueResourceRow[]>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [resources, directory] = await Promise.all([
|
||||||
|
getDashboardTopValueResources(db, {
|
||||||
|
limit: input.limit,
|
||||||
|
userRole: input.userRole,
|
||||||
|
}),
|
||||||
|
getAnonymizationDirectory(db),
|
||||||
|
]);
|
||||||
|
const result: TopValueResourceRow[] = anonymizeResources(resources, directory);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserRole(ctx: DashboardProcedureContext) {
|
||||||
|
return (ctx.session?.user as { role?: string } | undefined)?.role
|
||||||
|
?? ctx.dbUser?.systemRole
|
||||||
|
?? "USER";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardOverviewRead(ctx: DashboardProcedureContext) {
|
||||||
|
return getOverviewCached(ctx.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardStatisticsDetail(ctx: DashboardProcedureContext) {
|
||||||
|
const overview = await getOverviewCached(ctx.db);
|
||||||
|
return mapStatisticsDetail(overview);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardPeakTimesRead(
|
||||||
|
ctx: DashboardProcedureContext,
|
||||||
|
input: DashboardPeakTimesInput,
|
||||||
|
) {
|
||||||
|
return getPeakTimesCached(ctx.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardTopValueResourcesRead(
|
||||||
|
ctx: DashboardProcedureContext,
|
||||||
|
input: DashboardTopValueResourcesInput,
|
||||||
|
) {
|
||||||
|
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole: getUserRole(ctx) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardDemandRead(
|
||||||
|
ctx: DashboardProcedureContext,
|
||||||
|
input: DashboardDemandInput,
|
||||||
|
) {
|
||||||
|
return getDemandCached(ctx.db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardDetail(ctx: DashboardProcedureContext, input: DashboardDetailInput) {
|
||||||
|
const section = input.section;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const needsOverview = (
|
||||||
|
section === "all"
|
||||||
|
|| section === "peak_times"
|
||||||
|
|| section === "demand_pipeline"
|
||||||
|
|| section === "chargeability_overview"
|
||||||
|
);
|
||||||
|
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
|
||||||
|
const now = new Date();
|
||||||
|
const rangeStart = overview?.budgetBasis.windowStart
|
||||||
|
? new Date(overview.budgetBasis.windowStart)
|
||||||
|
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
const rangeEnd = overview?.budgetBasis.windowEnd
|
||||||
|
? new Date(overview.budgetBasis.windowEnd)
|
||||||
|
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
|
||||||
|
const userRole = getUserRole(ctx);
|
||||||
|
|
||||||
|
if (section === "all" || section === "peak_times") {
|
||||||
|
const peakTimes = await getPeakTimesCached(ctx.db, {
|
||||||
|
startDate: rangeStart.toISOString(),
|
||||||
|
endDate: rangeEnd.toISOString(),
|
||||||
|
granularity: "month",
|
||||||
|
groupBy: "project",
|
||||||
|
});
|
||||||
|
|
||||||
|
result.peakTimes = [...peakTimes]
|
||||||
|
.sort((left, right) => right.totalHours - left.totalHours)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((entry) => ({
|
||||||
|
month: entry.period,
|
||||||
|
totalHours: round1(entry.totalHours),
|
||||||
|
totalHoursPerDay: round1(entry.totalHours),
|
||||||
|
capacityHours: round1(entry.capacityHours),
|
||||||
|
utilizationPct: entry.utilizationPct ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "all" || section === "top_resources") {
|
||||||
|
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
|
||||||
|
result.topResources = resources.map((resource) => ({
|
||||||
|
name: resource.displayName,
|
||||||
|
eid: resource.eid,
|
||||||
|
chapter: resource.chapter ?? null,
|
||||||
|
lcr: fmtEur(resource.lcrCents),
|
||||||
|
valueScore: resource.valueScore ?? null,
|
||||||
|
countryCode: resource.countryCode ?? null,
|
||||||
|
federalState: resource.federalState ?? null,
|
||||||
|
metroCityName: resource.metroCityName ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "all" || section === "demand_pipeline") {
|
||||||
|
const demandRows = await getDemandCached(ctx.db, {
|
||||||
|
startDate: rangeStart.toISOString(),
|
||||||
|
endDate: rangeEnd.toISOString(),
|
||||||
|
groupBy: "project",
|
||||||
|
});
|
||||||
|
result.demandPipeline = demandRows
|
||||||
|
.map((row) => ({
|
||||||
|
project: `${row.name} (${row.shortCode})`,
|
||||||
|
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
|
||||||
|
requiredFTEs: row.requiredFTEs,
|
||||||
|
allocatedResources: row.resourceCount,
|
||||||
|
allocatedHours: row.allocatedHours,
|
||||||
|
calendarLocations: row.derivation?.calendarLocations ?? [],
|
||||||
|
}))
|
||||||
|
.filter((row) => row.needed > 0)
|
||||||
|
.sort((left, right) => right.needed - left.needed)
|
||||||
|
.slice(0, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "all" || section === "chargeability_overview") {
|
||||||
|
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
|
||||||
|
chapter: chapter.chapter ?? "Unassigned",
|
||||||
|
headcount: chapter.resourceCount,
|
||||||
|
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardChargeabilityOverviewRead(
|
||||||
|
ctx: DashboardProcedureContext,
|
||||||
|
input: DashboardChargeabilityOverviewInput,
|
||||||
|
) {
|
||||||
|
const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`;
|
||||||
|
const cached = await cacheGet<{
|
||||||
|
top: unknown[];
|
||||||
|
watchlist: unknown[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [overview, directory] = await Promise.all([
|
||||||
|
getDashboardChargeabilityOverview(ctx.db, {
|
||||||
|
includeProposed: input.includeProposed,
|
||||||
|
topN: input.topN,
|
||||||
|
watchlistThreshold: input.watchlistThreshold,
|
||||||
|
...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}),
|
||||||
|
...(input.departed !== undefined ? { departed: input.departed } : {}),
|
||||||
|
}),
|
||||||
|
getAnonymizationDirectory(ctx.db),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...overview,
|
||||||
|
top: anonymizeResources(overview.top, directory),
|
||||||
|
watchlist: anonymizeResources(overview.watchlist, directory),
|
||||||
|
};
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureContext) {
|
||||||
|
const cacheKey = "budgetForecast";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardBudgetForecast(ctx.db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardBudgetForecastDetail(ctx: DashboardProcedureContext) {
|
||||||
|
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
|
||||||
|
return mapBudgetForecastDetailRows(budgetForecast);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardSkillGapsRead(ctx: DashboardProcedureContext) {
|
||||||
|
const cacheKey = "skillGaps";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardSkillGaps(ctx.db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardSkillGapSummaryRead(ctx: DashboardProcedureContext) {
|
||||||
|
const cacheKey = "skillGapSummary";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardSkillGapSummary(ctx.db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardProjectHealthRead(ctx: DashboardProcedureContext) {
|
||||||
|
const cacheKey = "projectHealth";
|
||||||
|
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await getDashboardProjectHealth(ctx.db);
|
||||||
|
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardProjectHealthDetail(ctx: DashboardProcedureContext) {
|
||||||
|
const projectHealth = await getDashboardProjectHealth(ctx.db);
|
||||||
|
return mapProjectHealthDetailRows(projectHealth);
|
||||||
|
}
|
||||||
@@ -1,402 +1,59 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||||
import {
|
import {
|
||||||
getDashboardChargeabilityOverview,
|
dashboardChargeabilityOverviewInputSchema,
|
||||||
getDashboardDemand,
|
dashboardDemandInputSchema,
|
||||||
getDashboardOverview,
|
dashboardDetailInputSchema,
|
||||||
getDashboardPeakTimes,
|
dashboardPeakTimesInputSchema,
|
||||||
getDashboardTopValueResources,
|
dashboardTopValueResourcesInputSchema,
|
||||||
getDashboardBudgetForecast,
|
getDashboardBudgetForecastDetail,
|
||||||
getDashboardSkillGaps,
|
getDashboardBudgetForecastRead,
|
||||||
getDashboardSkillGapSummary,
|
getDashboardChargeabilityOverviewRead,
|
||||||
getDashboardProjectHealth,
|
getDashboardDemandRead,
|
||||||
} from "@capakraken/application";
|
getDashboardDetail,
|
||||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
getDashboardOverviewRead,
|
||||||
import { cacheGet, cacheSet } from "../lib/cache.js";
|
getDashboardPeakTimesRead,
|
||||||
import { fmtEur } from "../lib/format-utils.js";
|
getDashboardProjectHealthDetail,
|
||||||
|
getDashboardProjectHealthRead,
|
||||||
const DEFAULT_TTL = 60; // seconds
|
getDashboardSkillGapSummaryRead,
|
||||||
|
getDashboardSkillGapsRead,
|
||||||
function round1(value: number): number {
|
getDashboardStatisticsDetail,
|
||||||
return Math.round(value * 10) / 10;
|
getDashboardTopValueResourcesRead,
|
||||||
}
|
} from "./dashboard-procedure-support.js";
|
||||||
|
|
||||||
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
|
|
||||||
const projects = rows
|
|
||||||
.map((project) => {
|
|
||||||
const overall = project.compositeScore;
|
|
||||||
return {
|
|
||||||
projectId: project.id,
|
|
||||||
projectName: project.projectName,
|
|
||||||
shortCode: project.shortCode,
|
|
||||||
status: project.status,
|
|
||||||
overall,
|
|
||||||
budget: project.budgetHealth,
|
|
||||||
staffing: project.staffingHealth,
|
|
||||||
timeline: project.timelineHealth,
|
|
||||||
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((left, right) => left.overall - right.overall);
|
|
||||||
|
|
||||||
return {
|
|
||||||
projects,
|
|
||||||
summary: {
|
|
||||||
healthy: projects.filter((project) => project.rating === "healthy").length,
|
|
||||||
atRisk: projects.filter((project) => project.rating === "at_risk").length,
|
|
||||||
critical: projects.filter((project) => project.rating === "critical").length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapBudgetForecastDetailRows(rows: Awaited<ReturnType<typeof getDashboardBudgetForecast>>) {
|
|
||||||
return {
|
|
||||||
forecasts: rows.map((forecast) => ({
|
|
||||||
projectId: forecast.projectId ?? null,
|
|
||||||
projectName: forecast.projectName,
|
|
||||||
shortCode: forecast.shortCode,
|
|
||||||
clientId: forecast.clientId,
|
|
||||||
clientName: forecast.clientName,
|
|
||||||
budget: fmtEur(forecast.budgetCents),
|
|
||||||
budgetCents: forecast.budgetCents,
|
|
||||||
spent: fmtEur(forecast.spentCents),
|
|
||||||
spentCents: forecast.spentCents,
|
|
||||||
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
|
|
||||||
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
|
|
||||||
projected: forecast.burnRate > 0
|
|
||||||
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
|
|
||||||
: fmtEur(forecast.spentCents),
|
|
||||||
projectedCents: forecast.burnRate > 0
|
|
||||||
? Math.max(forecast.spentCents, forecast.budgetCents)
|
|
||||||
: forecast.spentCents,
|
|
||||||
burnRate: fmtEur(forecast.burnRate),
|
|
||||||
burnRateCents: forecast.burnRate,
|
|
||||||
utilization: `${forecast.pctUsed}%`,
|
|
||||||
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
|
|
||||||
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
|
|
||||||
calendarLocations: forecast.calendarLocations ?? [],
|
|
||||||
burnStatus: forecast.pctUsed >= 100
|
|
||||||
? "ahead"
|
|
||||||
: forecast.burnRate > 0
|
|
||||||
? "on_track"
|
|
||||||
: "not_started",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
|
|
||||||
return {
|
|
||||||
activeResources: overview.activeResources,
|
|
||||||
totalProjects: overview.totalProjects,
|
|
||||||
activeProjects: overview.activeProjects,
|
|
||||||
totalAllocations: overview.totalAllocations,
|
|
||||||
approvedVacations: overview.approvedVacations,
|
|
||||||
totalEstimates: overview.totalEstimates,
|
|
||||||
totalBudget: overview.budgetSummary.totalBudgetCents > 0
|
|
||||||
? fmtEur(overview.budgetSummary.totalBudgetCents)
|
|
||||||
: "N/A",
|
|
||||||
projectsByStatus: Object.fromEntries(
|
|
||||||
overview.projectsByStatus.map((entry) => [entry.status, entry.count]),
|
|
||||||
),
|
|
||||||
topChapters: [...overview.chapterUtilization]
|
|
||||||
.sort((left, right) => right.resourceCount - left.resourceCount)
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((chapter) => ({
|
|
||||||
chapter: chapter.chapter,
|
|
||||||
count: chapter.resourceCount,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOverviewCached(db: Parameters<typeof getDashboardOverview>[0]) {
|
|
||||||
const cacheKey = "overview";
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardOverview>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardOverview(db);
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPeakTimesCached(
|
|
||||||
db: Parameters<typeof getDashboardPeakTimes>[0],
|
|
||||||
input: { startDate: string; endDate: string; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource" },
|
|
||||||
) {
|
|
||||||
const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`;
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardPeakTimes>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardPeakTimes(db, {
|
|
||||||
startDate: new Date(input.startDate),
|
|
||||||
endDate: new Date(input.endDate),
|
|
||||||
granularity: input.granularity,
|
|
||||||
groupBy: input.groupBy,
|
|
||||||
});
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDemandCached(
|
|
||||||
db: Parameters<typeof getDashboardDemand>[0],
|
|
||||||
input: { startDate: string; endDate: string; groupBy: "project" | "person" | "chapter" },
|
|
||||||
) {
|
|
||||||
const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`;
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardDemand>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardDemand(db, {
|
|
||||||
startDate: new Date(input.startDate),
|
|
||||||
endDate: new Date(input.endDate),
|
|
||||||
groupBy: input.groupBy,
|
|
||||||
});
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTopValueResourcesCached(
|
|
||||||
db: Parameters<typeof getDashboardTopValueResources>[0],
|
|
||||||
input: { limit: number; userRole: string },
|
|
||||||
) {
|
|
||||||
const cacheKey = `topValue:${input.limit}:${input.userRole}`;
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof anonymizeResources>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const [resources, directory] = await Promise.all([
|
|
||||||
getDashboardTopValueResources(db, {
|
|
||||||
limit: input.limit,
|
|
||||||
userRole: input.userRole,
|
|
||||||
}),
|
|
||||||
getAnonymizationDirectory(db),
|
|
||||||
]);
|
|
||||||
const result = anonymizeResources(resources, directory);
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dashboardRouter = createTRPCRouter({
|
export const dashboardRouter = createTRPCRouter({
|
||||||
getOverview: controllerProcedure.query(async ({ ctx }) => {
|
getOverview: controllerProcedure.query(({ ctx }) => getDashboardOverviewRead(ctx)),
|
||||||
return getOverviewCached(ctx.db);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getStatisticsDetail: controllerProcedure.query(async ({ ctx }) => {
|
getStatisticsDetail: controllerProcedure.query(({ ctx }) => getDashboardStatisticsDetail(ctx)),
|
||||||
const overview = await getOverviewCached(ctx.db);
|
|
||||||
return mapStatisticsDetail(overview);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getPeakTimes: controllerProcedure
|
getPeakTimes: controllerProcedure
|
||||||
.input(
|
.input(dashboardPeakTimesInputSchema)
|
||||||
z.object({
|
.query(({ ctx, input }) => getDashboardPeakTimesRead(ctx, input)),
|
||||||
startDate: z.string().datetime(),
|
|
||||||
endDate: z.string().datetime(),
|
|
||||||
granularity: z.enum(["week", "month"]).default("month"),
|
|
||||||
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
return getPeakTimesCached(ctx.db, input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getTopValueResources: controllerProcedure
|
getTopValueResources: controllerProcedure
|
||||||
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
|
.input(dashboardTopValueResourcesInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getDashboardTopValueResourcesRead(ctx, input)),
|
||||||
const userRole =
|
|
||||||
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
|
|
||||||
return getTopValueResourcesCached(ctx.db, { limit: input.limit, userRole });
|
|
||||||
}),
|
|
||||||
|
|
||||||
getDemand: controllerProcedure
|
getDemand: controllerProcedure
|
||||||
.input(
|
.input(dashboardDemandInputSchema)
|
||||||
z.object({
|
.query(({ ctx, input }) => getDashboardDemandRead(ctx, input)),
|
||||||
startDate: z.string().datetime(),
|
|
||||||
endDate: z.string().datetime(),
|
|
||||||
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
return getDemandCached(ctx.db, input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getDetail: controllerProcedure
|
getDetail: controllerProcedure
|
||||||
.input(z.object({ section: z.string().optional().default("all") }))
|
.input(dashboardDetailInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(({ ctx, input }) => getDashboardDetail(ctx, input)),
|
||||||
const section = input.section;
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
const needsOverview =
|
|
||||||
section === "all"
|
|
||||||
|| section === "peak_times"
|
|
||||||
|| section === "demand_pipeline"
|
|
||||||
|| section === "chargeability_overview";
|
|
||||||
const overview = needsOverview ? await getOverviewCached(ctx.db) : null;
|
|
||||||
const now = new Date();
|
|
||||||
const rangeStart = overview?.budgetBasis.windowStart
|
|
||||||
? new Date(overview.budgetBasis.windowStart)
|
|
||||||
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
||||||
const rangeEnd = overview?.budgetBasis.windowEnd
|
|
||||||
? new Date(overview.budgetBasis.windowEnd)
|
|
||||||
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 5, 0));
|
|
||||||
const userRole =
|
|
||||||
(ctx.session.user as { role?: string } | undefined)?.role
|
|
||||||
?? ctx.dbUser?.systemRole
|
|
||||||
?? "USER";
|
|
||||||
|
|
||||||
if (section === "all" || section === "peak_times") {
|
|
||||||
const peakTimes = await getPeakTimesCached(ctx.db, {
|
|
||||||
startDate: rangeStart.toISOString(),
|
|
||||||
endDate: rangeEnd.toISOString(),
|
|
||||||
granularity: "month",
|
|
||||||
groupBy: "project",
|
|
||||||
});
|
|
||||||
|
|
||||||
result.peakTimes = [...peakTimes]
|
|
||||||
.sort((left, right) => right.totalHours - left.totalHours)
|
|
||||||
.slice(0, 6)
|
|
||||||
.map((entry) => ({
|
|
||||||
month: entry.period,
|
|
||||||
totalHours: round1(entry.totalHours),
|
|
||||||
totalHoursPerDay: round1(entry.totalHours),
|
|
||||||
capacityHours: round1(entry.capacityHours),
|
|
||||||
utilizationPct: entry.utilizationPct ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section === "all" || section === "top_resources") {
|
|
||||||
const resources = await getTopValueResourcesCached(ctx.db, { limit: 10, userRole });
|
|
||||||
result.topResources = resources.map((resource) => {
|
|
||||||
const topResource = resource as {
|
|
||||||
displayName: string;
|
|
||||||
eid: string;
|
|
||||||
chapter: string | null;
|
|
||||||
lcrCents: number;
|
|
||||||
valueScore: number | null;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
name: topResource.displayName,
|
|
||||||
eid: topResource.eid,
|
|
||||||
chapter: topResource.chapter ?? null,
|
|
||||||
lcr: fmtEur(topResource.lcrCents),
|
|
||||||
valueScore: topResource.valueScore ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section === "all" || section === "demand_pipeline") {
|
|
||||||
const demandRows = await getDemandCached(ctx.db, {
|
|
||||||
startDate: rangeStart.toISOString(),
|
|
||||||
endDate: rangeEnd.toISOString(),
|
|
||||||
groupBy: "project",
|
|
||||||
});
|
|
||||||
result.demandPipeline = demandRows
|
|
||||||
.map((row) => ({
|
|
||||||
project: `${row.name} (${row.shortCode})`,
|
|
||||||
needed: Math.max(0, round1(row.requiredFTEs - row.resourceCount)),
|
|
||||||
requiredFTEs: row.requiredFTEs,
|
|
||||||
allocatedResources: row.resourceCount,
|
|
||||||
allocatedHours: row.allocatedHours,
|
|
||||||
calendarLocations: row.derivation?.calendarLocations ?? [],
|
|
||||||
}))
|
|
||||||
.filter((row) => row.needed > 0)
|
|
||||||
.sort((left, right) => right.needed - left.needed)
|
|
||||||
.slice(0, 15);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section === "all" || section === "chargeability_overview") {
|
|
||||||
result.chargeabilityByChapter = (overview?.chapterUtilization ?? []).map((chapter) => ({
|
|
||||||
chapter: chapter.chapter ?? "Unassigned",
|
|
||||||
headcount: chapter.resourceCount,
|
|
||||||
avgTarget: `${Math.round(chapter.avgChargeabilityTarget)}%`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getChargeabilityOverview: controllerProcedure
|
getChargeabilityOverview: controllerProcedure
|
||||||
.input(
|
.input(dashboardChargeabilityOverviewInputSchema)
|
||||||
z.object({
|
.query(({ ctx, input }) => getDashboardChargeabilityOverviewRead(ctx, input)),
|
||||||
includeProposed: z.boolean().default(false),
|
|
||||||
topN: z.number().int().min(1).max(50).default(10),
|
|
||||||
watchlistThreshold: z.number().default(15),
|
|
||||||
countryIds: z.array(z.string()).optional(),
|
|
||||||
departed: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`;
|
|
||||||
type ChargeResult = Awaited<ReturnType<typeof getDashboardChargeabilityOverview>>;
|
|
||||||
const cached = await cacheGet<{
|
|
||||||
top: unknown[];
|
|
||||||
watchlist: unknown[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const [overview, directory] = await Promise.all([
|
getBudgetForecast: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastRead(ctx)),
|
||||||
getDashboardChargeabilityOverview(ctx.db, {
|
|
||||||
includeProposed: input.includeProposed,
|
|
||||||
topN: input.topN,
|
|
||||||
watchlistThreshold: input.watchlistThreshold,
|
|
||||||
...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}),
|
|
||||||
...(input.departed !== undefined ? { departed: input.departed } : {}),
|
|
||||||
}),
|
|
||||||
getAnonymizationDirectory(ctx.db),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = {
|
getBudgetForecastDetail: controllerProcedure.query(({ ctx }) => getDashboardBudgetForecastDetail(ctx)),
|
||||||
...overview,
|
|
||||||
top: anonymizeResources(overview.top, directory),
|
|
||||||
watchlist: anonymizeResources(overview.watchlist, directory),
|
|
||||||
};
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getBudgetForecast: controllerProcedure.query(async ({ ctx }) => {
|
getSkillGaps: controllerProcedure.query(({ ctx }) => getDashboardSkillGapsRead(ctx)),
|
||||||
const cacheKey = "budgetForecast";
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardBudgetForecast>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardBudgetForecast(ctx.db);
|
getSkillGapSummary: controllerProcedure.query(({ ctx }) => getDashboardSkillGapSummaryRead(ctx)),
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getBudgetForecastDetail: controllerProcedure.query(async ({ ctx }) => {
|
getProjectHealth: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthRead(ctx)),
|
||||||
const budgetForecast = await getDashboardBudgetForecast(ctx.db);
|
|
||||||
return mapBudgetForecastDetailRows(budgetForecast);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getSkillGaps: controllerProcedure.query(async ({ ctx }) => {
|
getProjectHealthDetail: controllerProcedure.query(({ ctx }) => getDashboardProjectHealthDetail(ctx)),
|
||||||
const cacheKey = "skillGaps";
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGaps>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardSkillGaps(ctx.db);
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getSkillGapSummary: controllerProcedure.query(async ({ ctx }) => {
|
|
||||||
const cacheKey = "skillGapSummary";
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardSkillGapSummary>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardSkillGapSummary(ctx.db);
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getProjectHealth: controllerProcedure.query(async ({ ctx }) => {
|
|
||||||
const cacheKey = "projectHealth";
|
|
||||||
const cached = await cacheGet<Awaited<ReturnType<typeof getDashboardProjectHealth>>>(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const result = await getDashboardProjectHealth(ctx.db);
|
|
||||||
await cacheSet(cacheKey, result, DEFAULT_TTL);
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getProjectHealthDetail: controllerProcedure.query(async ({ ctx }) => {
|
|
||||||
const projectHealth = await getDashboardProjectHealth(ctx.db);
|
|
||||||
return mapProjectHealthDetailRows(projectHealth);
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user