feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes

Major timeline enhancements:
- Right-click drag multi-selection with floating action bar (batch delete/assign)
- DemandPopover for demand strip details (replaces broken "Loading" modal)
- ResourceHoverCard on name hover showing skills, rates, role, chapter
- Merged heatmap+vacation tooltips into unified TimelineTooltip component
- Fixed overbooking blink animation (date normalization, z-index ordering)
- Fixed dark mode sticky column bleed-through in project view
- System roles admin page, notification task management, performance review docs

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
+2 -1
View File
@@ -116,10 +116,11 @@ export const assistantRouter = createTRPCRouter({
const temperature = settings?.aiTemperature ?? 0.7;
const model = settings?.azureOpenAiDeployment ?? "gpt-4o-mini";
// 2. Resolve granular permissions
// 2. Resolve granular permissions (using DB-based role defaults if available)
const permissions = resolvePermissions(
userRole as SystemRole,
(ctx.dbUser?.permissionOverrides as PermissionOverrides | null) ?? null,
ctx.roleDefaults ?? undefined,
);
const permissionList = [...permissions];
+2
View File
@@ -22,6 +22,7 @@ import { resourceRouter } from "./resource.js";
import { roleRouter } from "./role.js";
import { settingsRouter } from "./settings.js";
import { staffingRouter } from "./staffing.js";
import { systemRoleConfigRouter } from "./system-role-config.js";
import { timelineRouter } from "./timeline.js";
import { userRouter } from "./user.js";
import { utilizationCategoryRouter } from "./utilization-category.js";
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
chargeabilityReport: chargeabilityReportRouter,
calculationRule: calculationRuleRouter,
computationGraph: computationGraphRouter,
systemRoleConfig: systemRoleConfigRouter,
});
export type AppRouter = typeof appRouter;
+19 -7
View File
@@ -220,7 +220,7 @@ export const notificationRouter = createTRPCRouter({
});
}),
/** Get task counts for the current user */
/** Get task counts for the current user — single groupBy instead of 5 counts */
taskCounts: protectedProcedure.query(async ({ ctx }) => {
const userId = await resolveUserId(ctx);
const now = new Date();
@@ -230,11 +230,12 @@ export const notificationRouter = createTRPCRouter({
category: { in: ["TASK" as const, "APPROVAL" as const] },
};
const [open, inProgress, done, dismissed, overdue] = await Promise.all([
ctx.db.notification.count({ where: { ...where, taskStatus: "OPEN" } }),
ctx.db.notification.count({ where: { ...where, taskStatus: "IN_PROGRESS" } }),
ctx.db.notification.count({ where: { ...where, taskStatus: "DONE" } }),
ctx.db.notification.count({ where: { ...where, taskStatus: "DISMISSED" } }),
const [grouped, overdue] = await Promise.all([
ctx.db.notification.groupBy({
by: ["taskStatus"],
where,
_count: true,
}),
ctx.db.notification.count({
where: {
...where,
@@ -244,7 +245,18 @@ export const notificationRouter = createTRPCRouter({
}),
]);
return { open, inProgress, done, dismissed, overdue };
const counts: Record<string, number> = {};
for (const g of grouped) {
if (g.taskStatus) counts[g.taskStatus] = g._count;
}
return {
open: counts["OPEN"] ?? 0,
inProgress: counts["IN_PROGRESS"] ?? 0,
done: counts["DONE"] ?? 0,
dismissed: counts["DISMISSED"] ?? 0,
overdue,
};
}),
/** Update task status */
+48
View File
@@ -291,6 +291,54 @@ export const resourceRouter = createTRPCRouter({
return { resources, total, page, limit, nextCursor };
}),
/** Lightweight resource card for hover tooltips on the timeline. */
getHoverCard: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.id },
select: {
id: true,
displayName: true,
eid: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
skills: true,
availability: true,
isActive: true,
areaRole: { select: { id: true, name: true, color: true } },
country: { select: { name: true, code: true } },
managementLevel: { select: { name: true } },
resourceType: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const directory = await getAnonymizationDirectory(ctx.db);
const anon = anonymizeResource(resource, directory);
return {
id: anon.id,
displayName: anon.displayName ?? "",
eid: anon.eid ?? "",
chapter: resource.chapter,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
chargeabilityTarget: resource.chargeabilityTarget,
skills: resource.skills as Record<string, unknown>[],
isActive: resource.isActive,
resourceType: resource.resourceType,
areaRole: resource.areaRole,
country: resource.country,
managementLevel: resource.managementLevel,
};
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
+6
View File
@@ -48,6 +48,8 @@ export const settingsRouter = createTRPCRouter({
hasDalleApiKey: !!settings?.azureDalleApiKey,
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
// Timeline
timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50,
};
}),
@@ -94,6 +96,8 @@ export const settingsRouter = createTRPCRouter({
azureDalleApiKey: z.string().optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
// Timeline
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -144,6 +148,8 @@ export const settingsRouter = createTRPCRouter({
data.azureDalleApiKey = input.azureDalleApiKey || null;
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
// Timeline
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
await ctx.db.systemSettings.upsert({
where: { id: "singleton" },
@@ -0,0 +1,40 @@
import { z } from "zod";
import { adminProcedure, createTRPCRouter, invalidateRoleDefaultsCache, protectedProcedure } from "../trpc.js";
export const systemRoleConfigRouter = createTRPCRouter({
/** List all role configs (sorted by sortOrder) */
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.systemRoleConfig.findMany({
orderBy: { sortOrder: "asc" },
});
}),
/** Update a role's default permissions, label, description, and color */
update: adminProcedure
.input(
z.object({
role: z.string(),
label: z.string().min(1).optional(),
description: z.string().nullable().optional(),
color: z.string().nullable().optional(),
defaultPermissions: z.array(z.string()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const data: Record<string, unknown> = {};
if (input.label !== undefined) data.label = input.label;
if (input.description !== undefined) data.description = input.description;
if (input.color !== undefined) data.color = input.color;
if (input.defaultPermissions !== undefined) data.defaultPermissions = input.defaultPermissions;
const result = await ctx.db.systemRoleConfig.update({
where: { role: input.role as never },
data,
});
// Invalidate cached role defaults so changes take effect immediately
invalidateRoleDefaultsCache();
return result;
}),
});
+176
View File
@@ -721,6 +721,182 @@ export const timelineRouter = createTRPCRouter({
return allocation;
}),
/**
* Batch quick-assign multiple resources to a project for a date range.
* Used by the multi-selection floating action bar.
*/
batchQuickAssign: managerProcedure
.input(
z.object({
assignments: z
.array(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
status: z
.nativeEnum(AllocationStatus)
.default(AllocationStatus.PROPOSED),
}),
)
.min(1)
.max(50),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
// Validate all date ranges
for (const a of input.assignments) {
if (a.endDate < a.startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
}
const results = await ctx.db.$transaction(async (tx) => {
const created = [];
for (const a of input.assignments) {
const percentage = Math.min(
100,
Math.round((a.hoursPerDay / 8) * 100),
);
const metadata = {
source: "batchQuickAssign",
} satisfies Record<string, unknown>;
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: a.resourceId,
projectId: a.projectId,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
percentage,
role: a.role,
status: a.status,
metadata,
},
);
created.push(assignment);
}
return created;
});
// Fire SSE events
for (const assignment of results) {
emitAllocationCreated({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
});
}
return { count: results.length };
}),
/**
* Batch-shift multiple allocations by the same number of days.
* Used by multi-select drag on the timeline.
*/
batchShiftAllocations: managerProcedure
.input(
z.object({
allocationIds: z.array(z.string()).min(1).max(100),
daysDelta: z.number().int().min(-3650).max(3650),
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.daysDelta === 0) return { count: 0 };
// Load all allocations
const entries = await Promise.all(
input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
);
const resolved = entries.filter(
(e): e is NonNullable<typeof e> => e !== null,
);
if (resolved.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
}
const results = await ctx.db.$transaction(async (tx) => {
const updated = [];
for (const entry of resolved) {
const existing = entry.entry;
const newStart = new Date(existing.startDate);
const newEnd = new Date(existing.endDate);
if (input.mode === "move") {
newStart.setDate(newStart.getDate() + input.daysDelta);
newEnd.setDate(newEnd.getDate() + input.daysDelta);
} else if (input.mode === "resize-start") {
newStart.setDate(newStart.getDate() + input.daysDelta);
// Clamp: start must not exceed end
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + input.daysDelta);
// Clamp: end must not precede start
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
}
const result = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: existing.id,
demandRequirementUpdate: {
startDate: newStart,
endDate: newEnd,
},
assignmentUpdate: {
startDate: newStart,
endDate: newEnd,
},
},
);
updated.push(result.allocation);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationIds.join(","),
action: "UPDATE",
changes: {
operation: "batchShift",
mode: input.mode,
daysDelta: input.daysDelta,
count: resolved.length,
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return updated;
});
// Fire SSE events
for (const alloc of results) {
emitAllocationUpdated({
id: alloc.id,
projectId: alloc.projectId,
resourceId: alloc.resourceId,
});
}
return { count: results.length };
}),
/**
* Get budget status for a project.
*/
+25 -1
View File
@@ -12,9 +12,21 @@ import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
export const userRouter = createTRPCRouter({
/** Lightweight user list for task assignment (ADMIN + MANAGER) */
listAssignable: managerProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
},
orderBy: { name: "asc" },
});
}),
list: adminProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany({
select: {
@@ -23,11 +35,23 @@ export const userRouter = createTRPCRouter({
email: true,
systemRole: true,
createdAt: true,
lastLoginAt: true,
lastActiveAt: true,
permissionOverrides: true,
},
orderBy: { name: "asc" },
});
}),
/** Count of users active in the last 5 minutes */
activeCount: adminProcedure.query(async ({ ctx }) => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
const count = await ctx.db.user.count({
where: { lastActiveAt: { gte: fiveMinAgo } },
});
return { count };
}),
me: protectedProcedure.query(async ({ ctx }) => {
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({