feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -0,0 +1,297 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { staffingRouter } from "../router/staffing.js";
import { createCallerFactory } from "../trpc.js";
// Mock the pure-logic packages — we focus on the router/DB layer
vi.mock("@planarchy/staffing", () => ({
rankResources: vi.fn().mockImplementation((input: { resources: { id: string }[] }) =>
input.resources.map((r: { id: string }, i: number) => ({
resourceId: r.id,
score: 80 - i * 10,
breakdown: {
skillScore: 70,
availabilityScore: 90,
costScore: 80,
utilizationScore: 75,
},
})),
),
analyzeUtilization: vi.fn().mockReturnValue({
resourceId: "res_1",
displayName: "Alice",
totalDays: 20,
allocatedDays: 15,
utilizationPercent: 75,
chargeablePercent: 60,
overallocatedDays: 0,
dailyBreakdown: [],
}),
findCapacityWindows: vi.fn().mockReturnValue([
{
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
availableHoursPerDay: 6,
},
]),
}));
vi.mock("@planarchy/application", () => ({
listAssignmentBookings: vi.fn().mockResolvedValue([]),
}));
const createCaller = createCallerFactory(staffingRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleResource(overrides: Record<string, unknown> = {}) {
return {
id: "res_1",
displayName: "Alice",
eid: "alice",
lcrCents: 7500,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
skills: [
{ skill: "Compositing", proficiency: 4, isMainSkill: true },
{ skill: "Nuke", proficiency: 3, isMainSkill: false, category: "Software" },
],
isActive: true,
valueScore: 85,
chapter: "VFX",
...overrides,
};
}
// ─── getSuggestions ──────────────────────────────────────────────────────────
describe("staffing.getSuggestions", () => {
it("returns ranked suggestions for a staffing demand", async () => {
const resources = [
sampleResource(),
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 70 }),
];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("score");
});
it("filters resources by chapter when provided", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
chapter: "VFX",
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
it("returns empty array when no resources match", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result).toHaveLength(0);
});
it("passes budget constraint to ranking", async () => {
const resources = [sampleResource()];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const { rankResources } = await import("@planarchy/staffing");
const caller = createProtectedCaller(db);
await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
budgetLcrCentsPerHour: 10000,
});
expect(rankResources).toHaveBeenCalledWith(
expect.objectContaining({
budgetLcrCentsPerHour: 10000,
}),
);
});
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
describe("staffing.analyzeUtilization", () => {
it("returns utilization analysis for an existing resource", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const caller = createProtectedCaller(db);
const result = await caller.analyzeUtilization({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result).toHaveProperty("utilizationPercent");
expect(result.resourceId).toBe("res_1");
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.analyzeUtilization({
resourceId: "nonexistent",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
}),
).rejects.toThrow("Resource not found");
});
});
// ─── findCapacity ────────────────────────────────────────────────────────────
describe("staffing.findCapacity", () => {
it("returns capacity windows for an existing resource", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const caller = createProtectedCaller(db);
const result = await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("availableHoursPerDay");
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.findCapacity({
resourceId: "nonexistent",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
}),
).rejects.toThrow("Resource not found");
});
it("passes minAvailableHoursPerDay to engine", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const { findCapacityWindows } = await import("@planarchy/staffing");
const caller = createProtectedCaller(db);
await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
minAvailableHoursPerDay: 6,
});
expect(findCapacityWindows).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.any(Date),
expect.any(Date),
6,
);
});
});