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
+188 -8
View File
@@ -118,6 +118,39 @@ const INITIAL_RANGE_STATE: RangeState = {
startClientX: 0,
};
// ─── Multi-select state ────────────────────────────────────────────────────
export interface MultiSelectState {
isSelecting: boolean;
startX: number;
startY: number;
currentX: number;
currentY: number;
selectedAllocationIds: string[];
selectedResourceIds: string[];
dateRange: { start: Date; end: Date } | null;
/** When multi-dragging, the number of days all selected allocations are shifted */
multiDragDaysDelta: number;
/** Whether a multi-drag is currently in progress */
isMultiDragging: boolean;
/** The drag mode during multi-drag (move, resize-start, resize-end) */
multiDragMode: AllocDragMode;
}
const INITIAL_MULTI_SELECT: MultiSelectState = {
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
// ─── Hook ───────────────────────────────────────────────────────────────────
export interface AllocationMovedSnapshot {
@@ -134,20 +167,28 @@ export function useTimelineDrag({
onBlockClick,
onRangeSelected,
onAllocationMoved,
onShiftClickAlloc,
onMultiDragComplete,
}: {
cellWidth: number;
onShiftApplied?: (projectId: string) => void;
onBlockClick?: (info: BlockClickInfo) => void;
onRangeSelected?: (info: RangeSelectedInfo) => void;
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
onShiftClickAlloc?: (allocationId: string) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode) => void;
}) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
// Keep ref in sync with state so document-level handlers read the latest selection
multiSelectRef.current = multiSelectState;
// Keep always-current refs for values used inside document event handlers
const cellWidthRef = useRef(cellWidth);
@@ -166,6 +207,12 @@ export function useTimelineDrag({
const onAllocationMovedRef = useRef(onAllocationMoved);
onAllocationMovedRef.current = onAllocationMoved;
const onShiftClickAllocRef = useRef(onShiftClickAlloc);
onShiftClickAllocRef.current = onShiftClickAlloc;
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
onMultiDragCompleteRef.current = onMultiDragComplete;
const utils = trpc.useUtils();
// Project-shift preview
@@ -312,6 +359,54 @@ export function useTimelineDrag({
e.preventDefault();
e.stopPropagation();
const wasShift = e.shiftKey;
// Check if this allocation is part of a multi-selection → multi-drag mode
const ms = multiSelectRef.current;
const isMultiSelected =
ms.selectedAllocationIds.length > 1 &&
ms.selectedAllocationIds.includes(opts.allocationId);
if (isMultiSelected) {
// ── Multi-drag: move/resize all selected allocations together ──
const startMouseX = e.clientX;
let currentDaysDelta = 0;
const dragMode = opts.mode;
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
function handleMultiMove(ev: MouseEvent) {
const deltaX = ev.clientX - startMouseX;
const daysDelta = Math.round(deltaX / cellWidthRef.current);
if (daysDelta === currentDaysDelta) return;
currentDaysDelta = daysDelta;
setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta }));
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
}
function handleMultiUp() {
document.removeEventListener("mousemove", handleMultiMove);
document.removeEventListener("mouseup", handleMultiUp);
const finalDelta = currentDaysDelta;
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
if (finalDelta !== 0) {
onMultiDragCompleteRef.current?.(finalDelta, dragMode);
}
}
document.addEventListener("mousemove", handleMultiMove);
document.addEventListener("mouseup", handleMultiUp);
return;
}
// ── Single allocation drag ────────────────────────────────────────────
const initial: AllocDragState = {
isActive: true,
mode: opts.mode,
@@ -375,14 +470,20 @@ export function useTimelineDrag({
if (!alloc.isActive) return;
if (alloc.daysDelta === 0 && alloc.allocationId) {
// No movement → treat as click, open alloc popover
onBlockClickRef.current?.({
allocationId: alloc.allocationId,
projectId: alloc.projectId ?? "",
projectName: alloc.projectName ?? "",
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
// No movement → treat as click
if (wasShift) {
// Shift+Click → toggle multi-selection for this allocation
onShiftClickAllocRef.current?.(alloc.allocationId);
} else {
// Normal click → open alloc popover
onBlockClickRef.current?.({
allocationId: alloc.allocationId,
projectId: alloc.projectId ?? "",
projectName: alloc.projectName ?? "",
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
}
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
pendingSnapshotRef.current = {
allocationId: alloc.allocationId,
@@ -550,6 +651,81 @@ export function useTimelineDrag({
}
}, []);
// ── Multi-select (right-click drag) ─────────────────────────────────────────
const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 2) return;
e.preventDefault();
const initial: MultiSelectState = {
isSelecting: true,
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
multiSelectRef.current = initial;
setMultiSelectState(initial);
function handleMove(ev: MouseEvent) {
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
const updated: MultiSelectState = {
...ms,
currentX: ev.clientX,
currentY: ev.clientY,
};
multiSelectRef.current = updated;
setMultiSelectState(updated);
}
function handleUp(ev: MouseEvent) {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY);
if (distance < 5) {
// Minimal movement → not a drag selection, reset.
// Let existing onContextMenu handlers on allocation blocks handle right-click.
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
return;
}
// Keep the rectangle coordinates for the parent to compute intersection.
// isSelecting is set to false to indicate the drag is done, but the
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
// can resolve which allocations/resources fall within the selection.
const finished: MultiSelectState = {
...ms,
isSelecting: false,
currentX: ev.clientX,
currentY: ev.clientY,
};
multiSelectRef.current = finished;
setMultiSelectState(finished);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
}, []);
const clearMultiSelect = useCallback(() => {
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
}, []);
// ── Touch support ───────────────────────────────────────────────────────────
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
@@ -682,6 +858,8 @@ export function useTimelineDrag({
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview,
isPreviewLoading,
isApplying: applyShiftMutation.isPending,
@@ -693,6 +871,8 @@ export function useTimelineDrag({
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
// Touch equivalents
onProjectBarTouchStart,
onAllocTouchStart,