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:
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user