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:
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user