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
+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.
*/