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,443 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { entitlementRouter } from "../router/entitlement.js";
import { createCallerFactory } from "../trpc.js";
// Mock @planarchy/db to provide the enums used in the router
vi.mock("@planarchy/db", () => ({
VacationType: { ANNUAL: "ANNUAL", SICK: "SICK", OTHER: "OTHER", PUBLIC_HOLIDAY: "PUBLIC_HOLIDAY" },
VacationStatus: { APPROVED: "APPROVED", PENDING: "PENDING", REJECTED: "REJECTED" },
}));
const createCaller = createCallerFactory(entitlementRouter);
// ── 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,
},
});
}
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,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleEntitlement(overrides: Record<string, unknown> = {}) {
return {
id: "ent_1",
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 2,
usedDays: 5,
pendingDays: 3,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => {
it("returns vacation balance for a resource and year", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.year).toBe(2026);
expect(result.resourceId).toBe("res_1");
expect(result.entitledDays).toBe(30);
expect(result.remainingDays).toBe(22); // 30 - 5 - 3
expect(result).toHaveProperty("sickDays");
});
it("creates entitlement with carryover when none exists", async () => {
const prevEntitlement = sampleEntitlement({
id: "ent_prev",
year: 2025,
entitledDays: 28,
usedDays: 20,
pendingDays: 0,
});
const createdEntitlement = sampleEntitlement({
year: 2026,
entitledDays: 36, // 28 default + 8 carryover
carryoverDays: 8,
usedDays: 0,
pendingDays: 0,
});
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null) // current year not found
.mockResolvedValueOnce(prevEntitlement), // previous year found
create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(36);
expect(result.carryoverDays).toBe(8);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
carryoverDays: 8,
}),
}),
);
});
it("uses default of 28 days when no system settings exist", async () => {
const entitlement = sampleEntitlement({ entitledDays: 28, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(28);
});
it("counts sick days separately", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi
.fn()
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
.mockResolvedValueOnce([
{
startDate: new Date("2026-03-10"),
endDate: new Date("2026-03-12"),
isHalfDay: false,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.sickDays).toBe(3);
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
},
};
const caller = createManagerCaller(db);
const result = await caller.get({ resourceId: "res_1", year: 2026 });
expect(result.id).toBe("ent_1");
expect(result.entitledDays).toBe(30);
});
it("rejects access by a regular user (FORBIDDEN)", async () => {
const db = {
systemSettings: {
findUnique: vi.fn(),
},
vacationEntitlement: {
findUnique: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.get({ resourceId: "res_1", year: 2026 })).rejects.toThrow();
});
});
// ─── set ─────────────────────────────────────────────────────────────────────
describe("entitlement.set", () => {
it("updates existing entitlement", async () => {
const existing = sampleEntitlement();
const updated = { ...existing, entitledDays: 35 };
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 35,
});
expect(result.entitledDays).toBe(35);
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ent_1" },
data: { entitledDays: 35 },
}),
);
expect(db.vacationEntitlement.create).not.toHaveBeenCalled();
});
it("creates new entitlement when none exists", async () => {
const created = sampleEntitlement({ entitledDays: 30, carryoverDays: 0 });
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
});
expect(result.entitledDays).toBe(30);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
}),
);
expect(db.vacationEntitlement.update).not.toHaveBeenCalled();
});
});
// ─── bulkSet ─────────────────────────────────────────────────────────────────
describe("entitlement.bulkSet", () => {
it("upserts entitlements for all active resources (admin role)", async () => {
const resources = [{ id: "res_1" }, { id: "res_2" }, { id: "res_3" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
};
const caller = createAdminCaller(db);
const result = await caller.bulkSet({
year: 2026,
entitledDays: 30,
});
expect(result.updated).toBe(3);
expect(db.vacationEntitlement.upsert).toHaveBeenCalledTimes(3);
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isActive: true }),
}),
);
});
it("filters by resourceIds when provided", async () => {
const resources = [{ id: "res_1" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
};
const caller = createAdminCaller(db);
await caller.bulkSet({
year: 2026,
entitledDays: 30,
resourceIds: ["res_1"],
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
id: { in: ["res_1"] },
}),
}),
);
});
it("rejects bulk set by a manager (admin only)", async () => {
const db = {
resource: { findMany: vi.fn() },
vacationEntitlement: { upsert: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.bulkSet({ year: 2026, entitledDays: 30 }),
).rejects.toThrow();
});
});
// ─── getYearSummary ──────────────────────────────────────────────────────────
describe("entitlement.getYearSummary", () => {
it("returns summary for all active resources (manager role)", async () => {
const resources = [
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
];
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummary({ year: 2026 });
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("displayName");
expect(result[0]).toHaveProperty("remainingDays");
});
it("filters by chapter when provided", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
vacationEntitlement: {
findUnique: vi.fn(),
update: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
await caller.getYearSummary({ year: 2026, chapter: "VFX" });
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
});