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