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,854 @@
import { SystemRole } from "@planarchy/shared";
import { VacationStatus, VacationType } from "@planarchy/db";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { vacationRouter } from "../router/vacation.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitVacationCreated: vi.fn(),
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-12-31T00: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: "manager@example.com", name: "Manager", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "mgr_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "admin_1",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
function createUnauthenticatedCaller(db: Record<string, unknown>) {
return createCaller({
session: null,
db: db as never,
dbUser: null,
});
}
const sampleVacation = {
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
status: VacationStatus.PENDING,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
note: "Summer vacation",
isHalfDay: false,
halfDayPart: null,
requestedById: "user_1",
approvedById: null,
approvedAt: null,
rejectionReason: null,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
resource: { id: "res_1", displayName: "Alice", eid: "E-001" },
requestedBy: { id: "user_1", name: "User", email: "user@example.com" },
approvedBy: null,
};
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({});
expect(result).toHaveLength(1);
expect(result[0].id).toBe("vac_1");
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { startDate: "asc" },
take: 100,
}),
);
});
it("applies resourceId filter", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({ resourceId: "res_1" });
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_1" }),
}),
);
});
it("applies status and type filters", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
}),
}),
);
});
it("rejects unauthenticated users", async () => {
const db = { vacation: { findMany: vi.fn() } };
const caller = createUnauthenticatedCaller(db);
await expect(caller.list({})).rejects.toThrow("Authentication required");
});
});
describe("getById", () => {
it("returns vacation by id", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getById({ id: "vac_1" });
expect(result.id).toBe("vac_1");
expect(db.vacation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "missing" })).rejects.toThrow("Vacation not found");
});
});
describe("create", () => {
it("creates vacation as PENDING for regular user", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.PENDING,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.PENDING);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.PENDING,
resourceId: "res_1",
type: VacationType.ANNUAL,
}),
}),
);
});
it("creates vacation as APPROVED for manager", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
}),
}),
);
});
it("rejects overlapping vacation", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("Overlapping vacation already exists");
});
it("rejects when end date is before start date", async () => {
const db = {
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
};
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-05"),
endDate: new Date("2026-06-01"),
}),
).rejects.toThrow();
});
it("supports half-day vacations", async () => {
const createdVacation = {
...sampleVacation,
isHalfDay: true,
halfDayPart: "MORNING",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
halfDayPart: "MORNING",
});
expect(result.isHalfDay).toBe(true);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isHalfDay: true,
halfDayPart: "MORNING",
}),
}),
);
});
});
describe("approve", () => {
it("approves a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
rejectionReason: null,
}),
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Only PENDING, CANCELLED, or REJECTED vacations can be approved",
);
});
it("forbids regular users from approving", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow();
});
});
describe("reject", () => {
it("rejects a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
expect(result.status).toBe(VacationStatus.REJECTED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
}),
}),
);
});
it("throws when rejecting non-PENDING vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
"Only PENDING vacations can be rejected",
);
});
});
describe("cancel", () => {
it("cancels an existing vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
});
});
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
{ id: "vac_2", resourceId: "res_2" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
expect(result.approved).toBe(2);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_2"] } },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
}),
}),
);
});
it("only approves PENDING vacations from the requested set", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
expect(result.approved).toBe(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_already_approved"] }, status: VacationStatus.PENDING },
}),
);
});
});
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchReject({
ids: ["vac_1"],
rejectionReason: "Budget freeze",
});
expect(result.rejected).toBe(1);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Budget freeze",
}),
}),
);
});
});
describe("getForResource", () => {
it("returns approved vacations in date range", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getForResource({
resourceId: "res_1",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resourceId: "res_1",
status: VacationStatus.APPROVED,
}),
}),
);
});
});
describe("getPendingApprovals", () => {
it("returns all pending vacations for managers", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getPendingApprovals();
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: VacationStatus.PENDING },
}),
);
});
it("forbids regular users from viewing pending approvals", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.getPendingApprovals()).rejects.toThrow();
});
});
describe("getTeamOverlap", () => {
it("returns overlapping vacations for the same chapter", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
resource: { id: "res_2", displayName: "Bob", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resource: { chapter: "Animation" },
resourceId: { not: "res_1" },
}),
}),
);
});
it("returns empty array when resource has no chapter", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toEqual([]);
});
});
describe("batchCreatePublicHolidays", () => {
it("creates public holidays for all active resources (admin only)", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1" },
{ id: "res_2" },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBeGreaterThan(0);
expect(result.resources).toBe(2);
expect(db.vacation.create).toHaveBeenCalled();
});
it("skips already existing holidays", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing" }),
create: vi.fn(),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBe(0);
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("forbids non-admin users", async () => {
const db = {};
const caller = createManagerCaller(db);
await expect(
caller.batchCreatePublicHolidays({ year: 2026 }),
).rejects.toThrow("Admin role required");
});
});
describe("updateStatus", () => {
it("allows manager to approve via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
};
const caller = createManagerCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "APPROVED" });
expect(result.status).toBe(VacationStatus.APPROVED);
});
it("allows any user to cancel via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "CANCELLED" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("forbids non-manager from approving via updateStatus", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "vac_1", status: "APPROVED" }),
).rejects.toThrow("Manager role required to approve/reject");
});
it("throws NOT_FOUND when vacation does not exist", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "missing", status: "CANCELLED" }),
).rejects.toThrow("Vacation not found");
});
});
});