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,540 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { effortRuleRouter } from "../router/effort-rule.js";
import { createCallerFactory } from "../trpc.js";
// Mock the engine — we focus on the router/DB layer, not the pure engine logic
vi.mock("@planarchy/engine", () => ({
expandScopeToEffort: vi.fn().mockReturnValue({
lines: [
{
scopeItemName: "Shot_001",
scopeType: "shot",
discipline: "Compositing",
chapter: null,
hours: 16,
unitMode: "per_frame" as const,
unitCount: 200,
hoursPerUnit: 0.08,
},
],
warnings: [],
unmatchedScopeItems: [],
}),
aggregateByDiscipline: vi.fn().mockReturnValue({
Compositing: { totalHours: 16, lineCount: 1 },
}),
}));
const createCaller = createCallerFactory(effortRuleRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "ctrl@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_ctrl",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data factories ────────────────────────────────────────────────────
function sampleRuleSet(overrides: Record<string, unknown> = {}) {
return {
id: "ers_1",
name: "VFX Standard",
description: null,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
rules: [
{
id: "er_1",
ruleSetId: "ers_1",
scopeType: "shot",
discipline: "Compositing",
chapter: null,
unitMode: "per_frame",
hoursPerUnit: 0.08,
description: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
...overrides,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("effortRule.list", () => {
it("returns rule sets ordered by isDefault desc, name asc", async () => {
const sets = [sampleRuleSet(), sampleRuleSet({ id: "ers_2", name: "Animation", isDefault: false })];
const db = {
effortRuleSet: {
findMany: vi.fn().mockResolvedValue(sets),
},
};
const caller = createControllerCaller(db);
const result = await caller.list();
expect(result).toHaveLength(2);
expect(db.effortRuleSet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}),
);
});
});
// ─── getById ─────────────────────────────────────────────────────────────────
describe("effortRule.getById", () => {
it("returns the rule set when found", async () => {
const set = sampleRuleSet();
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(set),
},
};
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "ers_1" });
expect(result.id).toBe("ers_1");
expect(result.rules).toHaveLength(1);
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("effortRule.create", () => {
it("creates a rule set with rules", async () => {
const created = sampleRuleSet();
const db = {
effortRuleSet: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
name: "VFX Standard",
isDefault: false,
rules: [
{
scopeType: "shot",
discipline: "Compositing",
unitMode: "per_frame",
hoursPerUnit: 0.08,
sortOrder: 0,
},
],
});
expect(result.id).toBe("ers_1");
expect(db.effortRuleSet.create).toHaveBeenCalledTimes(1);
// isDefault was false, so updateMany should NOT have been called
expect(db.effortRuleSet.updateMany).not.toHaveBeenCalled();
});
it("unsets other defaults when creating a new default rule set", async () => {
const created = sampleRuleSet({ isDefault: true });
const db = {
effortRuleSet: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
await caller.create({
name: "VFX Standard",
isDefault: true,
rules: [],
});
expect(db.effortRuleSet.updateMany).toHaveBeenCalledWith({
where: { isDefault: true },
data: { isDefault: false },
});
});
});
// ─── update ──────────────────────────────────────────────────────────────────
describe("effortRule.update", () => {
it("updates name and description without touching rules", async () => {
const existing = sampleRuleSet();
const updated = { ...existing, name: "VFX Updated" };
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
effortRule: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.update({ id: "ers_1", name: "VFX Updated" });
expect(result.name).toBe("VFX Updated");
// No rules provided, so rule replacement should not happen
expect(db.effortRule.deleteMany).not.toHaveBeenCalled();
expect(db.effortRule.createMany).not.toHaveBeenCalled();
});
it("replaces rules when rules array is provided", async () => {
const existing = sampleRuleSet();
const updated = sampleRuleSet({ name: "Updated" });
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
effortRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
};
const caller = createManagerCaller(db);
await caller.update({
id: "ers_1",
rules: [
{ scopeType: "shot", discipline: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.1, sortOrder: 0 },
{ scopeType: "asset", discipline: "Modeling", unitMode: "per_item", hoursPerUnit: 8, sortOrder: 1 },
],
});
expect(db.effortRule.deleteMany).toHaveBeenCalledWith({ where: { ruleSetId: "ers_1" } });
expect(db.effortRule.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleSetId: "ers_1", discipline: "Lighting" }),
expect.objectContaining({ ruleSetId: "ers_1", discipline: "Modeling" }),
]),
});
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn(),
update: vi.fn(),
},
effortRule: { deleteMany: vi.fn(), createMany: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── delete ──────────────────────────────────────────────────────────────────
describe("effortRule.delete", () => {
it("deletes the rule set and returns its id", async () => {
const existing = sampleRuleSet();
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
};
const caller = createManagerCaller(db);
const result = await caller.delete({ id: "ers_1" });
expect(result).toEqual({ id: "ers_1" });
expect(db.effortRuleSet.delete).toHaveBeenCalledWith({ where: { id: "ers_1" } });
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── preview ─────────────────────────────────────────────────────────────────
describe("effortRule.preview", () => {
it("returns expansion result with aggregation", async () => {
const estimate = {
id: "est_1",
baseCurrency: "EUR",
versions: [
{
id: "v_1",
versionNumber: 1,
status: "WORKING",
scopeItems: [
{
id: "si_1",
name: "Shot_001",
scopeType: "shot",
frameCount: 200,
itemCount: null,
unitMode: "per_frame",
sortOrder: 0,
},
],
},
],
};
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" });
expect(result.lines).toHaveLength(1);
expect(result.scopeItemCount).toBe(1);
expect(result.ruleCount).toBe(1);
expect(result.aggregated).toBeDefined();
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "nope", ruleSetId: "ers_1" })).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const estimate = { id: "est_1", versions: [{ id: "v_1", scopeItems: [] }] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", ruleSetId: "nope" })).rejects.toThrow("Effort rule set not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" })).rejects.toThrow("Estimate has no versions");
});
});
// ─── applyRules ──────────────────────────────────────────────────────────────
describe("effortRule.applyRules", () => {
function makeEstimate(versionStatus: string, demandLines: unknown[] = []) {
return {
id: "est_1",
baseCurrency: "EUR",
versions: [
{
id: "v_1",
versionNumber: 1,
status: versionStatus,
scopeItems: [
{
id: "si_1",
name: "Shot_001",
scopeType: "shot",
frameCount: 200,
itemCount: null,
unitMode: "per_frame",
sortOrder: 0,
},
],
demandLines,
},
],
};
}
it("replaces existing demand lines in replace mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
ruleSetId: "ers_1",
mode: "replace",
});
expect(result.linesGenerated).toBe(1);
expect(db.estimateDemandLine.deleteMany).toHaveBeenCalledWith({
where: { estimateVersionId: "v_1" },
});
expect(db.estimateDemandLine.createMany).toHaveBeenCalledTimes(1);
expect(db.auditLog.create).toHaveBeenCalledTimes(1);
});
it("does not delete existing lines in append mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn(),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
ruleSetId: "ers_1",
mode: "append",
});
expect(result.linesGenerated).toBe(1);
expect(db.estimateDemandLine.deleteMany).not.toHaveBeenCalled();
});
it("rejects applying to a non-WORKING version", async () => {
const estimate = makeEstimate("SUBMITTED");
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Can only apply rules to a WORKING version");
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "nope", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", baseCurrency: "EUR", versions: [] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Estimate has no versions");
});
it("creates demand lines with correct metadata shape", async () => {
const estimate = makeEstimate("WORKING");
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
await caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" });
const createManyArg = db.estimateDemandLine.createMany.mock.calls[0][0];
const firstLine = createManyArg.data[0];
expect(firstLine.estimateVersionId).toBe("v_1");
expect(firstLine.lineType).toBe("LABOR");
expect(firstLine.currency).toBe("EUR");
expect(firstLine.costRateCents).toBe(0);
expect(firstLine.billRateCents).toBe(0);
expect(firstLine.metadata).toEqual(
expect.objectContaining({
effortRule: expect.objectContaining({
ruleSetId: "ers_1",
ruleSetName: "VFX Standard",
}),
}),
);
});
});